diff --git a/AUDIO_API.md b/AUDIO_API.md new file mode 100644 index 0000000..2253ae4 --- /dev/null +++ b/AUDIO_API.md @@ -0,0 +1,2656 @@ +# Nexora Engine — Audio API + +This document is the practical reference for Nexora's audio system. It is intended for game projects that use the engine as a library and covers the public audio workflow from loading a WAV file to advanced mixer routing, DSP chains, presets, metering, streaming, and runtime configuration. + +> [!NOTE] +> Nexora is still in active development. The API documented here reflects the current audio implementation after the dynamic-bus, NumPy mixer, DSP, send/return, preset, snapshot, and audio-editor work. + +## Contents + +- [1. Requirements and architecture](#1-requirements-and-architecture) +- [2. Imports](#2-imports) +- [3. Accessing audio from a game](#3-accessing-audio-from-a-game) +- [4. Quick start](#4-quick-start) +- [5. Loading audio assets](#5-loading-audio-assets) +- [6. AudioSource — one playback instance](#6-audiosource--one-playback-instance) +- [7. AudioPlayer — active source playback](#7-audioplayer--active-source-playback) +- [8. MusicPlayer — music queue and repeat](#8-musicplayer--music-queue-and-repeat) +- [9. Mixer and buses](#9-mixer-and-buses) +- [10. Send/return routing](#10-sendreturn-routing) +- [11. DSP effect system](#11-dsp-effect-system) +- [12. Built-in DSP effects](#12-built-in-dsp-effects) +- [13. DSP presets](#13-dsp-presets) +- [14. Metering, clipping, headroom, and master limiter](#14-metering-clipping-headroom-and-master-limiter) +- [15. Mixer snapshots](#15-mixer-snapshots) +- [16. Audio settings and audio.toml](#16-audio-settings-and-audiotoml) +- [17. Spatial audio](#17-spatial-audio) +- [18. Cache and memory management](#18-cache-and-memory-management) +- [19. Buffering and streaming](#19-buffering-and-streaming) +- [20. Standalone mixer editor and debug overlay](#20-standalone-mixer-editor-and-debug-overlay) +- [21. Common recipes](#21-common-recipes) +- [22. Public API reference](#22-public-api-reference) +- [23. Error handling and important rules](#23-error-handling-and-important-rules) +- [24. README link](#24-readme-link) + +--- + +## 1. Requirements and architecture + +Nexora's current audio stack uses: + +- Python **3.13+** according to the project package metadata +- NumPy **2.3+** for float32 PCM processing and block mixing +- SDL3 / PySDL3 for the output device +- stereo output (`channels=2`) +- WAV assets for the current built-in loader and streamer + +The mixer is designed to work with free-threaded Python builds. Audio sample processing is block-oriented and uses NumPy arrays internally. + +The normal signal path is: + +```text +AudioSource + ↓ +Assigned AudioBus + ↓ +Bus DSP chain + ├── Pre-fader sends ─────→ another bus + ↓ +Bus volume + pan + ├── Post-fader sends ────→ another bus + ↓ +Parent bus + ↓ +... + ↓ +Master headroom + ↓ +Master DSP chain + ↓ +Master volume + pan + ↓ +Optional master limiter + ↓ +Metering + ↓ +SDL audio device +``` + +The default bus graph is: + +```text +Master +├── Music +├── SFX +├── Ambient +└── Voice +``` + +`AudioSource` defaults to **Master** when no bus is specified. + +--- + +## 2. Imports + +The main audio types are exported from `nexora.audio`: + +```python +from nexora.audio import ( + AudioSystem, + AudioSource, + AudioSourceState, + AudioBus, + AudioSend, + AudioEffect, + AudioMixer, + AudioPlayer, + MusicPlayer, + RepeatMode, + Sound, + StreamedSound, + AudioCache, + GainEffect, + LimiterEffect, + LowPassFilterEffect, + HighPassFilterEffect, + ParametricEQEffect, + CompressorEffect, + DelayEffect, + ReverbEffect, + DistortionEffect, + NoiseGateEffect, + StereoWidthEffect, + AudioPreset, + AudioPresetRegistry, +) +``` + +For normal game code, prefer `self.audio` from `Game` rather than constructing a second `AudioSystem` yourself. + +--- + +## 3. Accessing audio from a game + +A running Nexora `Game` exposes the engine audio system through: + +```python +audio = self.audio +``` + +The engine creates, configures, initializes, updates, and shuts down this audio system for you. + +Useful runtime objects are available from it: + +```python +self.audio.player # source playback / mixer feeding +self.audio.mixer # bus graph, routing, sends, snapshots +self.audio.music # music queue helper +self.audio.cache # loaded audio assets +self.audio.presets # DSP preset registry +self.audio.device # low-level SDL audio device +``` + +For a standalone tool or test, an `AudioSystem` can be constructed manually: + +```python +from nexora.audio import AudioSystem + +audio = AudioSystem( + frequency=48_000, + channels=2, + target_queue_frames=2048, + max_update_frames=2048, +) + +audio.initialize() + +try: + # use audio + ... +finally: + audio.shutdown() +``` + +`channels` currently must be `2`. + +--- + +## 4. Quick start + +### Play one sound effect + +```python +from nexora.audio import AudioSource + +sound = self.audio.load("assets/audio/explosion.wav") +source = AudioSource(sound, bus="SFX") +self.audio.player.play(source) +``` + +### Change the source volume + +```python +source = AudioSource( + sound, + volume=0.7, + bus="SFX", +) +self.audio.player.play(source) +``` + +### Loop an ambience sound + +```python +ambience = self.audio.load_stream("assets/audio/rain.wav") + +source = AudioSource( + ambience, + loop=True, + volume=0.6, + bus="Ambient", +) + +self.audio.player.play(source) +``` + +### Play music + +```python +track = self.audio.load_stream("assets/audio/music/theme.wav") +self.audio.music.play(track) +``` + +### Stop a source + +```python +self.audio.player.stop(source) +``` + +A stopped or naturally finished source is automatically pruned from the player's active source list. + +--- + +## 5. Loading audio assets + +### `AudioSystem.load(path)` + +Loads and caches the complete WAV asset as a `Sound`. + +```python +sound = self.audio.load("assets/audio/hit.wav") +``` + +Use this for: + +- short SFX +- UI sounds +- footsteps +- weapon sounds +- short voice clips +- repeatedly played assets where full decoding is acceptable + +The decoded PCM is cached as a contiguous NumPy `float32` array on first use and shared read-only between playback instances. + +### `AudioSystem.load_stream(path)` + +Loads WAV metadata and streams decoded chunks while playing. + +```python +music = self.audio.load_stream("assets/audio/music/long_track.wav") +``` + +Use this for: + +- music +- long ambience tracks +- long dialogue or radio files +- large WAV files that should not be fully decoded into memory + +### Cache behavior + +Both methods use the same `AudioCache`. A normalized path is loaded only once. + +```python +self.audio.unload("assets/audio/hit.wav") +self.audio.clear_cache() +``` + +`unload()` removes one cached asset. `clear_cache()` removes all cached assets. + +> [!IMPORTANT] +> Removing an asset from the cache does not magically stop a source that is already using that asset. Stop/remove live sources first when you need deterministic release behavior. + +--- + +## 6. AudioSource — one playback instance + +An `AudioSource` is a single playback instance. Multiple `AudioSource` objects can play the same `Sound` simultaneously. + +```python +source = AudioSource( + sound, + volume=1.0, + loop=False, + position=(0.0, 0.0), + min_distance=1.0, + max_distance=1000.0, + pitch=1.0, + bus="Master", +) +``` + +### Constructor arguments + +| Argument | Default | Meaning | +|---|---:|---| +| `sound` | required | Loaded sound asset | +| `volume` | `1.0` | Source gain, range `0.0..1.0` | +| `loop` | `False` | Restart automatically at end | +| `position` | `(0.0, 0.0)` | 2D world position | +| `min_distance` | `1.0` | Full-volume distance | +| `max_distance` | `1000.0` | Zero-volume distance | +| `pitch` | `1.0` | Playback-rate / pitch multiplier, must be `> 0` | +| `bus` | `"Master"` | Destination bus, bus object, name, ID, or `None` | + +### Playback state + +```python +source.state +source.playing +source.paused +source.stopped +``` + +States are defined by `AudioSourceState`: + +```python +AudioSourceState.STOPPED +AudioSourceState.PLAYING +AudioSourceState.PAUSED +``` + +### Position and progress + +```python +source.position # current frame +source.position_seconds # current time in seconds +source.duration # total duration +source.remaining # seconds remaining +source.progress # 0.0 .. 1.0 +``` + +### Seeking + +```python +source.seek(48_000) # seek by frame +source.seek_seconds(5.0) +``` + +Seeking below zero raises `ValueError`. Seeking past the end clamps to the final frame. + +### Volume + +```python +source.volume = 0.5 +``` + +Valid range: `0.0 .. 1.0`. + +### Pitch + +```python +source.pitch = 0.8 +source.pitch = 1.25 +``` + +Pitch must be greater than zero. + +### Looping + +```python +source.loop = True +``` + +### Bus assignment + +```python +source.bus = "SFX" +source.bus = self.audio.get_bus("Weapons") +``` + +The default is `Master`. + +### Fades + +```python +source.fade_in(1.0) +source.fade_out(0.5) +``` + +The duration is in seconds. A completed fade-out stops the source and closes an active stream reader. + +### Direct source controls + +```python +source.play() +source.pause() +source.resume() +source.stop() +``` + +For normal engine playback, prefer using `self.audio.player.play(source)` so the player owns and mixes the source. + +### Volume helpers + +```python +source.get_source_volume() +source.get_effective_volume(self.audio.mixer) +``` + +`get_source_volume()` is source/fade gain only. `get_effective_volume()` reports the effective source gain including bus routing volume/mute semantics. + +--- + +## 7. AudioPlayer — active source playback + +The central player is available as: + +```python +player = self.audio.player +``` + +### Play + +```python +player.play(source) +``` + +If the source is not already registered, it is added automatically and started. + +### Pause and resume + +```python +player.pause(source) +player.resume(source) +``` + +### Stop + +```python +player.stop(source) +``` + +### Stop everything + +```python +player.stop_all() +``` + +### Explicit source ownership + +```python +player.add(source) +player.remove(source) +``` + +`remove()` also closes a stream reader held by the source. + +### Active sources + +```python +player.sources +player.has_playing_sources +``` + +`player.sources` returns a tuple snapshot. + +### Cleanup + +```python +removed_count = player.prune_stopped_sources() +``` + +Normally this cleanup already happens automatically after mixing. It exists as a public maintenance/debug helper. + +### Listener position + +The player owns the spatial listener: + +```python +player.listener.position = (camera_x, camera_y) +# or +player.listener.set_position(camera_x, camera_y) +``` + +--- + +## 8. MusicPlayer — music queue and repeat + +Nexora includes a convenience music player: + +```python +music = self.audio.music +``` + +Music is routed to the `Music` bus. + +### Play immediately + +```python +track = self.audio.load_stream("assets/audio/music/title.wav") +music.play(track) +``` + +### Queue tracks + +```python +music.enqueue(track_a) +music.enqueue(track_b) +music.play_next() +``` + +### Previous track + +```python +music.previous() +``` + +### Shuffle + +```python +music.set_shuffle(True) +``` + +### Repeat + +```python +from nexora.audio import RepeatMode + +music.set_repeat(RepeatMode.OFF) +music.set_repeat(RepeatMode.ONE) +music.set_repeat(RepeatMode.ALL) +``` + +### Playback control + +```python +music.pause() +music.resume() +music.stop() +``` + +### Queue state + +```python +music.current +music.source +music.queue +music.history +music.playing +music.paused +``` + +### Clear bookkeeping + +```python +music.clear_queue() +music.clear_history() +``` + +The engine/game loop is expected to call the music update logic as part of normal runtime integration where required by the project. + +--- + +## 9. Mixer and buses + +The high-level API is available directly on `AudioSystem`: + +```python +audio = self.audio +``` + +The lower-level mixer is: + +```python +mixer = self.audio.mixer +``` + +### Default buses + +The default graph contains: + +```text +Master +Music -> Master +SFX -> Master +Ambient -> Master +Voice -> Master +``` + +These are normal `AudioBus` objects, not special channel types. + +### Create a bus + +```python +weapons = audio.create_bus( + "Weapons", + parent="SFX", + volume=0.9, + muted=False, + pan=0.0, + solo=False, +) +``` + +Buses can be nested arbitrarily as long as the graph stays acyclic: + +```python +audio.create_bus("Weapons", parent="SFX") +audio.create_bus("Rifles", parent="Weapons") +audio.create_bus("Pistols", parent="Weapons") +``` + +### Check and retrieve buses + +```python +audio.has_bus("Weapons") +bus = audio.get_bus("Weapons") +all_buses = audio.get_buses() +``` + +Names are resolved case-insensitively by the mixer. + +### Stable bus IDs + +Every bus has a stable ID: + +```python +bus.id +same_bus = audio.get_bus_by_id(bus.id) +``` + +The ID survives a rename. + +### Rename a bus + +```python +audio.rename_bus("Weapons", "Combat") +``` + +Existing `AudioBus` object references remain valid. Legacy source routes stored as matching strings are migrated by the high-level `AudioSystem.rename_bus()` helper. + +### Parent routing + +```python +audio.set_bus_parent("Weapons", "SFX") +audio.set_bus_parent("Weapons", "Master") +``` + +A routing cycle is rejected. + +### Remove a bus + +```python +audio.remove_bus( + "Weapons", + reassign_to="SFX", + reparent_children=True, +) +``` + +The high-level removal API safely reassigns active sources using that bus. + +### Bus volume + +```python +audio.set_bus_volume("SFX", 0.7) +value = audio.get_bus_volume("SFX") +``` + +Bus volume is linear gain. It must not be negative. + +### Bus pan + +```python +audio.set_bus_pan("Music", -0.25) +pan = audio.get_bus_pan("Music") +``` + +Range: + +```text +-1.0 = left + 0.0 = unchanged stereo balance ++1.0 = right +``` + +Bus pan is a balance operation; `0.0` leaves the stereo signal unchanged. + +### Mute + +```python +audio.set_bus_muted("SFX", True) +audio.set_bus_muted("SFX", False) +``` + +Direct bus methods are also available: + +```python +bus.mute() +bus.unmute() +bus.toggle_mute() +``` + +### Solo + +```python +audio.set_bus_solo("SFX", True) +``` + +Solo is hierarchy-aware. Necessary ancestors remain audible so a soloed child can still reach `Master`; descendants of a soloed parent remain audible as part of that branch. + +### Effective volume + +```python +bus.effective_volume +``` + +This follows the parent chain and accounts for mute state. + +--- + +## 10. Send/return routing + +Sends duplicate part of a bus signal into another bus without replacing the normal parent route. + +Typical use: shared reverb. + +```text +Weapons ─────────→ SFX ─→ Master + └─ 25% send ─→ Reverb ─→ Master +``` + +### Create a return bus + +```python +audio.create_bus("Reverb", parent="Master") +``` + +### Add a send + +```python +send = audio.add_send( + "Weapons", + "Reverb", + amount=0.25, + pre_fader=False, + enabled=True, +) +``` + +`amount` must be non-negative. + +### Pre-fader vs post-fader + +```python +pre_fader=True +``` + +Pre-fader send point: + +```text +Bus input -> DSP -> SEND -> volume/pan -> parent +``` + +Post-fader send point (`pre_fader=False`, default): + +```text +Bus input -> DSP -> volume/pan -> SEND -> parent +``` + +Use pre-fader when the send level should remain independent of the source bus fader. Use post-fader for the common reverb/delay case where lowering the dry bus also lowers the send. + +### Modify a send + +```python +audio.set_send_amount(send.id, 0.5) +audio.set_send_enabled(send.id, False) +audio.set_send_pre_fader(send.id, True) +``` + +### List sends + +```python +sends = audio.get_sends() +``` + +Lower-level mixer helpers: + +```python +self.audio.mixer.get_send(send.id) +self.audio.mixer.get_sends_from("Weapons") +``` + +### Remove a send + +```python +audio.remove_send(send.id) +``` + +### Routing safety + +Parent routes and send routes form one acyclic routing graph. Nexora rejects enabled routes that would introduce a feedback cycle. + +--- + +## 11. DSP effect system + +Every `AudioBus` has an ordered DSP chain. + +```python +bus = audio.get_bus("SFX") +bus.effects +``` + +Effects run in insertion order. + +### Add an effect + +```python +from nexora.audio import CompressorEffect + +compressor = audio.add_bus_effect( + "SFX", + CompressorEffect(), +) +``` + +Insert at a specific position: + +```python +audio.add_bus_effect("SFX", effect, index=0) +``` + +### Remove an effect + +```python +audio.remove_bus_effect("SFX", compressor) +``` + +### Clear the chain + +```python +audio.clear_bus_effects("SFX") +``` + +### Read the chain + +```python +effects = audio.get_bus_effects("SFX") +``` + +### Wet/dry + +Every built-in effect inherits `AudioEffect.wet`: + +```python +effect.wet = 0.35 +``` + +Range: `0.0 .. 1.0`. + +- `0.0` = fully dry +- `1.0` = fully processed + +The same can be changed through the high-level API by effect index: + +```python +audio.set_bus_effect_wet("SFX", 0, 0.35) +``` + +### Bypass + +Bypass one effect: + +```python +effect.bypassed = True +``` + +or: + +```python +audio.set_bus_effect_bypassed("SFX", 0, True) +``` + +Bypass the complete bus DSP chain: + +```python +audio.set_bus_effects_bypassed("SFX", True) +``` + +### Runtime parameters + +All built-in effects expose parameters through `set_parameter()` and `get_parameter()`: + +```python +effect.set_parameter("threshold_db", -18.0) +value = effect.get_parameter("threshold_db") +``` + +Through a bus/index: + +```python +audio.set_bus_effect_parameter( + "SFX", + 0, + "threshold_db", + -18.0, +) + +value = audio.get_bus_effect_parameter( + "SFX", + 0, + "threshold_db", +) +``` + +This is the preferred mechanism for editor-driven or animated DSP parameter changes. + +### Resetting stateful DSP + +Filters, compressor smoothing, delay lines, reverb lines, and gates keep state across mixer blocks. + +Reset one effect: + +```python +effect.reset() +``` + +Reset all bus effects: + +```python +bus.reset_effects() +``` + +--- + +## 12. Built-in DSP effects + +All effects accept: + +```python +enabled=True +wet=1.0 +``` + +unless a different `wet` default is listed below. + +### `GainEffect` + +```python +GainEffect(gain=1.0, wet=1.0) +``` + +Parameters: + +- `gain`: linear multiplier + +Example: + +```python +bus.add_effect(GainEffect(0.8)) +``` + +### `LimiterEffect` + +```python +LimiterEffect(threshold=1.0, wet=1.0) +``` + +Parameters: + +- `threshold`: linear absolute clipping threshold, must be `> 0` + +This is a lightweight hard limiter/clipper. + +### `LowPassFilterEffect` + +```python +LowPassFilterEffect( + cutoff_hz=12000.0, + q=0.70710678, +) +``` + +Parameters: + +- `cutoff_hz > 0` +- `q > 0` + +The runtime coefficient calculation clamps the actual cutoff below Nyquist. + +Use cases: + +- underwater/muffled sound +- occlusion +- distant/behind-wall effects +- removing harsh highs + +### `HighPassFilterEffect` + +```python +HighPassFilterEffect( + cutoff_hz=80.0, + q=0.70710678, +) +``` + +Parameters: + +- `cutoff_hz > 0` +- `q > 0` + +Use cases: + +- radio/telephone processing +- rumble removal +- thin/small-speaker character + +### `ParametricEQEffect` + +```python +ParametricEQEffect( + frequency_hz=1000.0, + gain_db=0.0, + q=1.0, +) +``` + +Parameters: + +- `frequency_hz > 0` +- `gain_db`: boost/cut in dB +- `q > 0` + +Use several instances for several EQ bands: + +```python +bus.add_effect(ParametricEQEffect(120.0, 3.0, 0.8)) +bus.add_effect(ParametricEQEffect(1200.0, -2.0, 1.3)) +bus.add_effect(ParametricEQEffect(7000.0, 2.0, 0.7)) +``` + +### `CompressorEffect` + +```python +CompressorEffect( + threshold_db=-12.0, + ratio=4.0, + attack_ms=10.0, + release_ms=100.0, + makeup_gain_db=0.0, +) +``` + +Parameters: + +- `threshold_db`: threshold in dBFS +- `ratio >= 1.0` +- `attack_ms >= 0` +- `release_ms >= 0` +- `makeup_gain_db`: output makeup gain + +Read current gain reduction: + +```python +compressor.gain_reduction_db +``` + +The compressor is stereo-linked: both channels use the same gain reduction. + +### `DelayEffect` + +```python +DelayEffect( + delay_seconds=0.25, + feedback=0.35, + wet=0.25, +) +``` + +Parameters: + +- `delay_seconds > 0` +- `feedback` range `-0.99 .. 0.99` + +The internal delayed output is fully wet; the inherited `wet` property controls dry/wet blending. + +### `ReverbEffect` + +```python +ReverbEffect( + room_size=0.5, + damping=0.35, + decay=0.65, + wet=0.2, +) +``` + +Parameters: + +- `room_size`: `0.0 .. 1.0` +- `damping`: `0.0 .. 1.0` +- `decay`: `0.0 <= decay < 1.0` + +Use cases: + +- caves +- halls +- rooms +- shared reverb return buses + +### `DistortionEffect` + +```python +DistortionEffect( + drive=2.0, + output_gain_db=0.0, + mode="soft", + wet=1.0, +) +``` + +Parameters: + +- `drive > 0` +- `output_gain_db`: post-distortion gain in dB +- `mode`: `"soft"` or `"hard"` + +`soft` uses smooth saturation; `hard` produces harder clipping. + +### `NoiseGateEffect` + +```python +NoiseGateEffect( + threshold_db=-40.0, + attack_ms=2.0, + hold_ms=25.0, + release_ms=80.0, +) +``` + +Parameters: + +- `threshold_db`: gate threshold in dBFS +- `attack_ms >= 0` +- `hold_ms >= 0` +- `release_ms >= 0` + +Current smoothed gate gain: + +```python +gate.gain +``` + +### `StereoWidthEffect` + +```python +StereoWidthEffect(width=1.0) +``` + +Range: `0.0 .. 2.0`. + +```text +0.0 = mono +1.0 = original stereo width +2.0 = double side component +``` + +Mono buffers are intentionally left unchanged. + +--- + +## 13. DSP presets + +The audio system includes a thread-safe preset registry: + +```python +self.audio.presets +``` + +Presets store effect configuration, not live effect instances. Applying a preset creates fresh effect instances, which is important for stateful filters, delay, and reverb. + +### Built-in presets + +Nexora currently includes: + +| Preset | Intended use | +|---|---| +| `Radio` | Band-limited compressed radio/voice communication | +| `Telephone` | Narrow-band telephone sound | +| `Underwater` | Muffled underwater ambience | +| `Cave` | Large reflective cave space | +| `Hall` | Large hall reverb | +| `Distorted Speaker` | Overdriven small speaker / PA character | +| `Wide Music` | Stereo widening and gentle glue compression | + +### Apply a preset + +```python +audio.apply_audio_preset("Radio", "Voice") +``` + +By default this replaces the existing effect chain. + +Append instead: + +```python +audio.apply_audio_preset( + "Cave", + "Ambient", + replace=False, +) +``` + +### Register a custom preset + +```python +from nexora.audio import ( + HighPassFilterEffect, + CompressorEffect, + ReverbEffect, +) + +audio.register_audio_preset( + "Boss Voice", + [ + HighPassFilterEffect(180.0), + CompressorEffect(-18.0, 4.0), + ReverbEffect(wet=0.15), + ], + description="Processed boss dialogue", +) +``` + +### Capture the current chain from a bus + +```python +audio.capture_bus_preset( + "My Weapon Chain", + "Weapons", +) +``` + +### Query presets + +```python +preset = audio.get_audio_preset("Radio") +all_presets = audio.get_audio_presets() +``` + +Preset names are case-insensitive in the registry. + +### Remove a preset + +```python +audio.remove_audio_preset("Boss Voice") +``` + +### Save a preset as JSON + +```python +audio.save_audio_preset( + "Boss Voice", + "audio_presets/boss_voice.json", +) +``` + +### Load a preset from JSON + +```python +preset = audio.load_audio_preset( + "audio_presets/boss_voice.json", +) +``` + +Overwrite an existing preset: + +```python +audio.load_audio_preset( + "audio_presets/boss_voice.json", + overwrite=True, +) +``` + +Override the name while loading: + +```python +audio.load_audio_preset( + "audio_presets/boss_voice.json", + name="Boss Voice Variant", +) +``` + +Preset JSON format currently uses version `1`. + +--- + +## 14. Metering, clipping, headroom, and master limiter + +Metering is post-DSP/post-fader/post-pan for each bus. + +### Linear peak + +```python +left, right = audio.get_bus_peak("Master") +``` + +### Linear RMS + +```python +left, right = audio.get_bus_rms("Master") +``` + +### dBFS peak + +```python +left_db, right_db = audio.get_bus_peak_dbfs("Master") +``` + +### dBFS RMS + +```python +left_db, right_db = audio.get_bus_rms_dbfs("Master") +``` + +### Peak hold + +```python +left, right = audio.get_bus_peak_hold("Master") +left_db, right_db = audio.get_bus_peak_hold_dbfs("Master") +``` + +Clear hold: + +```python +audio.clear_bus_peak_hold("Master") +``` + +### Clip indicator + +```python +if audio.get_bus_clipped("Master"): + print("Master clipped") +``` + +### Headroom + +Reserve headroom before the master DSP chain: + +```python +audio.set_headroom_db(-3.0) +``` + +Read it: + +```python +audio.get_headroom_db() +``` + +This converts dB to a linear gain internally and is applied to the Master before its bus DSP chain. + +### Dedicated master limiter + +Enable: + +```python +limiter = audio.enable_master_limiter(0.98) +``` + +Disable: + +```python +audio.disable_master_limiter() +``` + +The dedicated master limiter runs as the final master safety stage after Master volume/pan and before metering/output. + +A useful game default is: + +```python +audio.set_headroom_db(-3.0) +audio.enable_master_limiter(0.98) +``` + +--- + +## 15. Mixer snapshots + +Snapshots capture mixer state so it can be restored later. + +```python +snapshot = audio.create_mixer_snapshot() +``` + +Restore: + +```python +audio.restore_mixer_snapshot(snapshot) +``` + +The snapshot includes the mixer topology/state needed by the current implementation, including bus state, built-in DSP states, sends, headroom, and master-limiter configuration. + +### Keep current topology + +```python +audio.restore_mixer_snapshot( + snapshot, + restore_topology=False, +) +``` + +Use cases: + +- temporarily change the whole mix for a cutscene +- switch between gameplay and pause-menu mixes +- save an editor mixer state +- restore a known debug state + +Example: + +```python +normal_mix = audio.create_mixer_snapshot() + +audio.set_bus_volume("Music", 0.3) +audio.apply_audio_preset("Underwater", "Master", replace=False) + +# later +audio.restore_mixer_snapshot(normal_mix) +``` + +--- + +## 16. Audio settings and audio.toml + +Nexora's layered audio settings now describe the dynamic bus graph. The old separate `[volume]` section is not part of the current format. + +Default structure: + +```toml +[buses.master] +volume = 1.0 +muted = false + +[buses.music] +parent = "master" +volume = 1.0 +muted = false + +[buses.sfx] +parent = "master" +volume = 1.0 +muted = false + +[buses.ambient] +parent = "master" +volume = 1.0 +muted = false + +[buses.voice] +parent = "master" +volume = 1.0 +muted = false +``` + +Custom buses can also be declared: + +```toml +[buses.weapons] +parent = "sfx" +volume = 0.9 +muted = false + +[buses.footsteps] +parent = "sfx" +volume = 0.75 +muted = false +``` + +The supported settings per bus in `audio.toml` are currently: + +- `parent` +- `volume` +- `muted` + +### Game settings facade + +A running `Game` exposes: + +```python +self.audio_settings +``` + +Read: + +```python +value = self.audio_settings.get( + "buses.music.volume", + 1.0, +) +``` + +Write: + +```python +self.audio_settings.set( + "buses.music.volume", + 0.5, +) +``` + +Attribute-style access is also supported: + +```python +self.audio_settings.buses.music.volume = 0.5 +self.audio_settings.buses.music.muted = False +``` + +After manually changing stored settings when needed: + +```python +self.apply_audio_settings() +``` + +Reset user overrides: + +```python +self.reset_audio_settings() +``` + +> [!IMPORTANT] +> Do not write keys such as `volume.master`. The current format is `buses.master.volume`. + +--- + +## 17. Spatial audio + +Nexora currently provides stereo 2D attenuation and panning. + +### Set the listener + +Usually track the player/camera position: + +```python +self.audio.player.listener.position = ( + player_x, + player_y, +) +``` + +or: + +```python +self.audio.player.listener.set_position( + player_x, + player_y, +) +``` + +### Create a positioned source + +```python +source = AudioSource( + sound, + bus="SFX", + position=(enemy_x, enemy_y), + min_distance=2.0, + max_distance=40.0, +) + +self.audio.player.play(source) +``` + +### Update a moving source + +```python +source.position_2d = (enemy.x, enemy.y) +``` + +### Query spatial calculation + +```python +volume = source.get_spatial_volume( + self.audio.player.listener.position +) + +attenuation, pan = source.get_spatial_parameters( + self.audio.player.listener.position +) +``` + +`min_distance` is full level. The source attenuates toward zero by `max_distance`. + +--- + +## 18. Cache and memory management + +The current audio implementation is designed to avoid unbounded source retention. + +### Finished sources + +Naturally finished or explicitly stopped sources are pruned from `AudioPlayer`. + +This means short one-shot sounds should normally be used like this: + +```python +source = AudioSource(sound, bus="SFX") +self.audio.player.play(source) +``` + +You do not need to store the source unless you need to control it later. + +### Long-lived sources + +Keep a reference when you need to: + +- stop/pause it later +- move it spatially +- fade it +- change pitch or volume + +```python +self.ambience_source = AudioSource( + ambience, + loop=True, + bus="Ambient", +) +self.audio.player.play(self.ambience_source) +``` + +Cleanup: + +```python +self.audio.player.stop(self.ambience_source) +self.ambience_source = None +``` + +### Stream readers + +Stopping/removing/finishing streamed sources closes their stream reader. This prevents streamed NumPy windows and file handles from being retained after playback ends. + +### PCM cache + +For a decoded `Sound`: + +```python +sound.clear_pcm_cache() +``` + +This drops the decoded NumPy PCM cache while keeping the original `AudioBuffer` bytes in the `Sound`. + +### Entire asset cache + +```python +self.audio.clear_cache() +``` + +--- + +## 19. Buffering and streaming + +`AudioPlayer` keeps the SDL audio queue near a target number of frames instead of filling it based directly on render-frame rate. + +### Target queued frames + +```python +audio.target_queue_frames = 2048 +``` + +Must be greater than zero. + +### Maximum frames mixed per update + +```python +audio.max_update_frames = 2048 +``` + +Must be greater than zero. + +These values are also available directly on `audio.player`. + +### Choosing values + +Smaller buffers can reduce latency but increase the chance of underruns on a busy system. Larger buffers increase latency but provide more safety. + +A useful starting point at 48 kHz is the engine default: + +```text +target_queue_frames = 2048 +max_update_frames = 2048 +``` + +### Streaming recommendation + +Prefer: + +```python +audio.load_stream(path) +``` + +for long audio assets. Prefer: + +```python +audio.load(path) +``` + +for frequently reused short effects. + +--- + +## 20. Standalone mixer editor and debug overlay + +### Standalone mixer editor + +Run: + +```powershell +python -m nexora --audioedit +``` + +or with a project path: + +```powershell +python -m nexora --audioedit MyGame +``` + +The editor is intended to manage: + +- bus hierarchy +- volume/pan +- mute/solo +- meters +- DSP chains and effect parameters +- presets +- sends +- mixer state save/load + +### Debug mixer overlay + +The normal debug key is configurable and defaults to F3. + +Current intended behavior: + +```text +Debug key -> normal debug overlay +Shift + debug key -> audio/mixer debug overlay +``` + +The audio overlay displays live bus stereo meters and routing/debug information without acting as the full mixer editor. + +--- + +## 21. Common recipes + +### 21.1 One-shot UI sound + +```python +click = self.audio.load("assets/audio/ui/click.wav") + +self.audio.player.play( + AudioSource( + click, + volume=0.7, + bus="SFX", + ) +) +``` + +### 21.2 Footstep bus + +Create once during initialization: + +```python +self.audio.create_bus( + "Footsteps", + parent="SFX", + volume=0.8, +) +``` + +Play: + +```python +step = self.audio.load("assets/audio/steps/stone_01.wav") +self.audio.player.play( + AudioSource(step, bus="Footsteps") +) +``` + +### 21.3 2D enemy sound + +```python +source = AudioSource( + self.audio.load("assets/audio/enemy/growl.wav"), + bus="SFX", + position=(enemy.x, enemy.y), + min_distance=3.0, + max_distance=30.0, +) +self.audio.player.play(source) +``` + +When the enemy moves: + +```python +source.position_2d = (enemy.x, enemy.y) +``` + +### 21.4 Long ambience stream + +```python +rain = self.audio.load_stream("assets/audio/ambience/rain.wav") + +self.rain = AudioSource( + rain, + bus="Ambient", + volume=0.5, + loop=True, +) + +self.audio.player.play(self.rain) +``` + +### 21.5 Music playlist + +```python +from nexora.audio import RepeatMode + +track_a = self.audio.load_stream("assets/music/a.wav") +track_b = self.audio.load_stream("assets/music/b.wav") +track_c = self.audio.load_stream("assets/music/c.wav") + +self.audio.music.play(track_a) +self.audio.music.enqueue(track_b) +self.audio.music.enqueue(track_c) +self.audio.music.set_repeat(RepeatMode.ALL) +``` + +### 21.6 Shared reverb return + +```python +from nexora.audio import ReverbEffect + +self.audio.create_bus("Reverb", parent="Master") +self.audio.add_bus_effect( + "Reverb", + ReverbEffect( + room_size=0.8, + damping=0.35, + decay=0.85, + wet=1.0, + ), +) + +self.audio.add_send( + "SFX", + "Reverb", + amount=0.18, + pre_fader=False, +) + +self.audio.add_send( + "Voice", + "Reverb", + amount=0.10, + pre_fader=False, +) +``` + +For a dedicated return bus, using a fully wet reverb is usually the cleanest setup because the dry signal already follows the normal route. + +### 21.7 Radio voice + +```python +self.audio.apply_audio_preset( + "Radio", + "Voice", +) +``` + +### 21.8 Underwater master effect + +Save current mix: + +```python +self.normal_mix = self.audio.create_mixer_snapshot() +``` + +Apply: + +```python +self.audio.apply_audio_preset( + "Underwater", + "Master", + replace=False, +) +``` + +Restore: + +```python +self.audio.restore_mixer_snapshot(self.normal_mix) +``` + +### 21.9 Duck music manually during dialogue + +```python +self.audio.set_bus_volume("Music", 0.35) +``` + +Restore: + +```python +self.audio.set_bus_volume("Music", 1.0) +``` + +For a more advanced implementation, automate the bus volume or add a future side-chain/ducking system. + +### 21.10 Master safety setup + +```python +self.audio.set_headroom_db(-3.0) +self.audio.enable_master_limiter(0.98) +``` + +### 21.11 Runtime DSP editing + +```python +from nexora.audio import LowPassFilterEffect + +filter_fx = self.audio.add_bus_effect( + "Music", + LowPassFilterEffect(12000.0), +) + +# Muffle dynamically +filter_fx.cutoff_hz = 1500.0 + +# or by index for generic/editor code +self.audio.set_bus_effect_parameter( + "Music", + 0, + "cutoff_hz", + 1500.0, +) +``` + +--- + +## 22. Public API reference + +This section is a compact inventory of the public audio surface. + +### `AudioSystem` + +Constructor: + +```python +AudioSystem( + *, + frequency=AudioDevice.DEFAULT_FREQUENCY, + channels=AudioDevice.DEFAULT_CHANNELS, + target_queue_frames=2048, + max_update_frames=2048, +) +``` + +Properties / owned systems: + +```python +audio.initialized +audio.frequency +audio.channels +audio.target_queue_frames +audio.max_update_frames + +audio.mixer +audio.player +audio.music +audio.cache +audio.device +audio.presets +``` + +Lifecycle: + +```python +audio.initialize() +audio.shutdown() +``` + +Assets/cache: + +```python +audio.load(path) +audio.load_stream(path) +audio.unload(path) +audio.clear_cache() +``` + +Bus management: + +```python +audio.create_bus(name, *, parent="Master", volume=1.0, + muted=False, pan=0.0, solo=False, bus_id=None) +audio.has_bus(name) +audio.get_buses() +audio.get_bus(name) +audio.get_bus_by_id(bus_id) +audio.set_bus_parent(name, parent) +audio.remove_bus(name, *, reassign_to="Master", reparent_children=True) +audio.rename_bus(name_or_id, new_name) +``` + +Bus controls: + +```python +audio.set_bus_volume(name, volume) +audio.get_bus_volume(name) +audio.set_bus_pan(name, pan) +audio.get_bus_pan(name) +audio.set_bus_solo(name, solo) +audio.get_bus_solo(name) +audio.set_bus_muted(name, muted) +audio.get_bus_muted(name) +``` + +DSP chain: + +```python +audio.add_bus_effect(name, effect, *, index=None) +audio.remove_bus_effect(name, effect) +audio.clear_bus_effects(name) +audio.get_bus_effects(name) +audio.set_bus_effects_bypassed(name, bypassed) +audio.set_bus_effect_bypassed(name, index, bypassed) +audio.set_bus_effect_wet(name, index, wet) +audio.set_bus_effect_parameter(name, index, parameter, value) +audio.get_bus_effect_parameter(name, index, parameter) +``` + +Sends: + +```python +audio.add_send(source, target, *, amount=1.0, + pre_fader=False, enabled=True) +audio.remove_send(send_or_id) +audio.get_sends() +audio.set_send_amount(send_id, amount) +audio.set_send_enabled(send_id, enabled) +audio.set_send_pre_fader(send_id, pre_fader) +``` + +Metering: + +```python +audio.get_bus_peak(name) +audio.get_bus_rms(name) +audio.get_bus_peak_dbfs(name) +audio.get_bus_rms_dbfs(name) +audio.get_bus_peak_hold(name) +audio.get_bus_peak_hold_dbfs(name) +audio.get_bus_clipped(name) +audio.clear_bus_peak_hold(name) +``` + +Master safety: + +```python +audio.set_headroom_db(value) +audio.get_headroom_db() +audio.enable_master_limiter(threshold=0.98) +audio.disable_master_limiter() +``` + +Presets: + +```python +audio.register_audio_preset(name, effects, *, description="", overwrite=False) +audio.capture_bus_preset(name, bus, *, description="", overwrite=False) +audio.apply_audio_preset(preset, bus, *, replace=True) +audio.get_audio_preset(name) +audio.get_audio_presets() +audio.remove_audio_preset(name) +audio.save_audio_preset(name, path) +audio.load_audio_preset(path, *, overwrite=False, name=None) +``` + +Snapshots: + +```python +audio.create_mixer_snapshot() +audio.restore_mixer_snapshot(snapshot, *, restore_topology=True) +``` + +Reset: + +```python +audio.reset() +``` + +### `AudioSource` + +Constructor: + +```python +AudioSource( + sound, + *, + volume=1.0, + loop=False, + position=(0.0, 0.0), + min_distance=1.0, + max_distance=1000.0, + pitch=1.0, + bus="Master", +) +``` + +Properties: + +```python +source.position +source.duration +source.position_seconds +source.remaining +source.progress +source.state +source.playing +source.paused +source.stopped +source.volume +source.bus +source.pitch +source.loop +source.position_2d +source.min_distance +source.max_distance +source.fading +``` + +Methods: + +```python +source.seek(frame) +source.seek_seconds(seconds) +source.fade_in(duration) +source.fade_out(duration) +source.play() +source.pause() +source.resume() +source.stop() +source.get_source_volume() +source.get_effective_volume(mixer) +source.get_spatial_volume(listener_position) +source.get_spatial_parameters(listener_position) +``` + +### `AudioPlayer` + +Properties: + +```python +player.output_channels +player.output_frequency +player.target_queue_frames +player.max_update_frames +player.sources +player.has_playing_sources +player.listener +``` + +Methods: + +```python +player.add(source) +player.remove(source) +player.play(source) +player.pause(source) +player.resume(source) +player.stop(source) +player.stop_all() +player.prune_stopped_sources() +player.mix(frame_count) +player.update() +``` + +`mix()` and `update()` are normally engine/runtime responsibilities. Game code usually calls playback-control methods instead. + +### `AudioBus` + +Constructor: + +```python +AudioBus( + name, + volume=1.0, + muted=False, + *, + parent=None, + pan=0.0, + solo=False, + bus_id=None, +) +``` + +Properties: + +```python +bus.id +bus.name +bus.volume +bus.muted +bus.pan +bus.solo +bus.effects_bypassed +bus.parent +bus.effective_volume +bus.effects + +bus.peak +bus.rms +bus.peak_hold +bus.clipped +bus.peak_dbfs +bus.rms_dbfs +bus.peak_hold_dbfs +``` + +Methods: + +```python +bus.add_effect(effect, *, index=None) +bus.remove_effect(effect) +bus.clear_effects() +bus.reset_effects() +bus.process_effects(buffer, *, sample_rate, channels) +bus.update_meter(buffer) +bus.reset_meter(clear_hold=True) +bus.clear_peak_hold() +bus.mute() +bus.unmute() +bus.toggle_mute() +``` + +Game code normally does not call `process_effects()` or `update_meter()` directly; those are mixer-facing operations. + +### `AudioSend` + +Fields: + +```python +send.id +send.source_bus_id +send.target_bus_id +send.amount +send.pre_fader +send.enabled +``` + +### `AudioMixer` + +Most projects should use `AudioSystem` wrappers. Advanced/editor code can access `audio.mixer` directly. + +Important methods: + +```python +mixer.create_bus(...) +mixer.add_bus(bus) +mixer.has_bus(name_or_id) +mixer.get_bus(name_or_id) +mixer.get_bus_by_id(bus_id) +mixer.get_buses() +mixer.rename_bus(name_or_id, new_name) +mixer.set_bus_parent(name_or_id, parent) +mixer.remove_bus(name_or_id, *, reparent_children_to="Master") + +mixer.add_send(...) +mixer.remove_send(...) +mixer.get_send(send_id) +mixer.get_sends_from(bus) +mixer.set_send_amount(send_id, amount) +mixer.set_send_enabled(send_id, enabled) +mixer.set_send_pre_fader(send_id, pre_fader) + +mixer.processing_order() +mixer.buses_by_depth(deepest_first=False) +mixer.solo_active() +mixer.is_bus_audible(bus) + +mixer.create_snapshot() +mixer.restore_snapshot(snapshot, restore_topology=True) +mixer.reset() +``` + +Properties: + +```python +mixer.buses +mixer.sends +mixer.master +mixer.headroom_db +mixer.headroom_gain +mixer.master_limiter_enabled +mixer.master_limiter +``` + +### `MusicPlayer` + +```python +music.set_shuffle(enabled) +music.set_repeat(mode) +music.play(sound) +music.enqueue(sound) +music.play_next() +music.previous() +music.update() +music.pause() +music.resume() +music.stop() +music.clear_queue() +music.clear_history() +``` + +Properties: + +```python +music.shuffle +music.repeat +music.current +music.source +music.queue +music.history +music.playing +music.paused +``` + +### `Sound` + +```python +Sound.load(path) +``` + +Properties: + +```python +sound.buffer +sound.path +sound.duration +sound.frequency +sound.channels +sound.size +sound.pcm +``` + +Method: + +```python +sound.clear_pcm_cache() +``` + +### `StreamedSound` + +```python +StreamedSound.load(path) +``` + +Properties: + +```python +stream.path +stream.buffer +stream.frequency +stream.channels +stream.bytes_per_sample +stream.sample_count +stream.duration +stream.size +``` + +Methods: + +```python +reader = stream.open_reader() +stream.clear_pcm_cache() +``` + +`WavStreamReader` supports: + +```python +reader.seek(frame) +reader.sample(frame, channel) +reader.close() +``` + +Normal game code should let `AudioSource` manage readers instead of reading samples manually. + +### `AudioCache` + +```python +cache.load(path) +cache.load_stream(path) +cache.get(path) +cache.contains(path) +cache.remove(path) +cache.clear() +``` + +Properties: + +```python +cache.sounds +cache.count +``` + +### `AudioEffect` + +Common properties: + +```python +effect.enabled +effect.bypassed +effect.wet +``` + +Common methods: + +```python +effect.process(buffer, *, sample_rate, channels) +effect.process_mixed(buffer, scratch, *, sample_rate, channels) +effect.set_parameter(name, value) +effect.get_parameter(name) +effect.reset() +effect.to_state() +``` + +Game code normally lets the mixer call `process()`/`process_mixed()`. + +### `AudioPreset` + +Create from effects: + +```python +AudioPreset.from_effects(name, effects, description="") +``` + +Create from a bus: + +```python +AudioPreset.from_bus(name, bus, description="") +``` + +Methods: + +```python +preset.instantiate_effects() +preset.to_dict() +AudioPreset.from_dict(data) +``` + +Fields: + +```python +preset.name +preset.description +preset.effects +``` + +### `AudioPresetRegistry` + +```python +registry.register(preset, overwrite=False) +registry.register_effects(name, effects, description="", overwrite=False) +registry.capture_bus(name, bus, description="", overwrite=False) +registry.unregister(name) +registry.has(name) +registry.get(name) +registry.apply(name, bus, replace=True) +registry.save(name, path) +registry.load(path, overwrite=False, name=None) +``` + +Properties: + +```python +registry.presets +registry.names +``` + +--- + +## 23. Error handling and important rules + +### Stereo only + +The current engine mixer requires: + +```python +channels == 2 +``` + +Trying to construct the audio system with another output-channel count raises `ValueError`. + +### Source volume + +```text +0.0 <= volume <= 1.0 +``` + +### Source pitch + +```text +pitch > 0 +``` + +### Spatial distances + +```text +min_distance > 0 +max_distance > 0 +min_distance <= max_distance +``` + +### Bus graph + +Do not create parent cycles or enabled send feedback loops. The mixer validates routing and rejects cyclic graphs. + +### Master bus + +Treat `Master` as the final root bus. Custom root-style buses without a parent are still routed to Master by the player so the master safety/DSP stage cannot be skipped. + +### Bus names vs IDs + +Use names for readable gameplay code: + +```python +source.bus = "Weapons" +``` + +Use stable bus IDs when building editor/serialization systems that must survive renames: + +```python +bus_id = bus.id +``` + +### Effects and threads + +Built-in effect parameters are protected by locks and can be changed at runtime. Avoid directly mutating internal DSP arrays/state from gameplay threads. + +### Do not share stateful effect objects between unrelated buses + +A live `DelayEffect`, `ReverbEffect`, filter, gate, or compressor contains processing state. Create separate instances for separate buses. Presets already do this correctly. + +### Short effects vs streamed assets + +Use full `Sound` loading for short/reused SFX and streamed loading for long assets. This avoids unnecessarily decoding a long music file into RAM while still keeping short sounds cheap to replay. + +### Current file-format scope + +The built-in asset path currently loads/streams WAV audio. Do not assume MP3/OGG support unless a separate decoder has been added to the project. + +--- + + + +## Minimal complete example + +```python +from nexora import Game +from nexora.audio import ( + AudioSource, + CompressorEffect, + ReverbEffect, +) + + +class MyGame(Game): + def initialize(self) -> None: + # Build project routing once. + self.audio.create_bus("Weapons", parent="SFX", volume=0.9) + self.audio.create_bus("Reverb", parent="Master") + + # Shared wet reverb return. + self.audio.add_bus_effect( + "Reverb", + ReverbEffect( + room_size=0.75, + damping=0.35, + decay=0.82, + wet=1.0, + ), + ) + + self.audio.add_send( + "Weapons", + "Reverb", + amount=0.18, + ) + + # Control dynamics on the dry weapon bus. + self.audio.add_bus_effect( + "Weapons", + CompressorEffect( + threshold_db=-14.0, + ratio=3.0, + attack_ms=4.0, + release_ms=90.0, + ), + ) + + # Master safety. + self.audio.set_headroom_db(-3.0) + self.audio.enable_master_limiter(0.98) + + # Load once, replay many times. + self.weapon_sound = self.audio.load( + "assets/audio/weapons/shot.wav" + ) + + def fire_weapon(self, x: float, y: float) -> None: + self.audio.player.play( + AudioSource( + self.weapon_sound, + bus="Weapons", + position=(x, y), + min_distance=2.0, + max_distance=45.0, + ) + ) + + def update(self, delta_time: float) -> None: + del delta_time + + # Keep the listener at the player/camera position. + self.audio.player.listener.set_position( + self.player_x, + self.player_y, + ) +``` + +This example uses the intended high-level model: + +```text +load asset once + ↓ +create one AudioSource per playback + ↓ +route source into a named bus + ↓ +process bus DSP + ↓ +optional send/return + ↓ +Master safety + ↓ +output +``` diff --git a/examples/audio/audio_system_example.py b/examples/audio/audio_system_example.py index b15c809..06d3a44 100644 --- a/examples/audio/audio_system_example.py +++ b/examples/audio/audio_system_example.py @@ -3,7 +3,6 @@ from nexora import Game from nexora.audio import ( - AudioChannel, AudioSource, RepeatMode, ) @@ -25,7 +24,7 @@ class AudioSystemExample(Game): - fade in / fade out - 2D spatial audio - listener movement - - channel volume + - bus volume - audio bus volume - audio bus mute - master volume @@ -361,7 +360,6 @@ def initialize( self.source = AudioSource( self.sound, - channel=AudioChannel.SFX, volume=1.0, @@ -381,7 +379,6 @@ def initialize( self.spatial_source = AudioSource( self.sound, - channel=AudioChannel.SFX, volume=1.0, @@ -943,8 +940,8 @@ def update( - 0.1, ) - self.audio.set_volume( - AudioChannel.MASTER, + self.audio.set_bus_volume( + "Master", self.master_volume, ) @@ -962,8 +959,8 @@ def update( + 0.1, ) - self.audio.set_volume( - AudioChannel.MASTER, + self.audio.set_bus_volume( + "Master", self.master_volume, ) diff --git a/examples/audio/step_sequencer_ui.py b/examples/audio/step_sequencer_ui.py index 54eab76..58521ca 100644 --- a/examples/audio/step_sequencer_ui.py +++ b/examples/audio/step_sequencer_ui.py @@ -7,7 +7,6 @@ import sdl3 from nexora.audio import ( - AudioChannel, AudioSource, AudioSystem, ) @@ -219,7 +218,6 @@ def main() -> None: kick_source = AudioSource( kick_sound, - channel=AudioChannel.SFX, volume=1.0, pitch=1.0, bus=sfx_bus, @@ -227,7 +225,6 @@ def main() -> None: hat_source = AudioSource( hat_sound, - channel=AudioChannel.SFX, volume=0.8, pitch=1.0, bus=sfx_bus, @@ -235,7 +232,6 @@ def main() -> None: snare_source = AudioSource( snare_sound, - channel=AudioChannel.SFX, volume=1.0, pitch=1.0, bus=sfx_bus, @@ -569,8 +565,8 @@ def main() -> None: emit=False, ) - audio.set_volume( - AudioChannel.MASTER, + audio.set_bus_volume( + "Master", master_volume, ) @@ -974,8 +970,8 @@ def master_changed( master_volume = value - audio.set_volume( - AudioChannel.MASTER, + audio.set_bus_volume( + "Master", value, ) diff --git a/nexora/audio/__init__.py b/nexora/audio/__init__.py index 6216c3f..1c41d54 100644 --- a/nexora/audio/__init__.py +++ b/nexora/audio/__init__.py @@ -1,8 +1,23 @@ from .audio import AudioSystem from .buffer import AudioBuffer from .bus import AudioBus -from .channel import AudioChannel from .device import AudioDevice +from .effects import ( + AudioEffect, + GainEffect, + LimiterEffect, + LowPassFilterEffect, + HighPassFilterEffect, + ParametricEQEffect, + CompressorEffect, + DelayEffect, + ReverbEffect, + DistortionEffect, + NoiseGateEffect, + StereoWidthEffect, +) +from .send import AudioSend +from .preset import AudioPreset, AudioPresetRegistry, create_builtin_presets from .mixer import AudioMixer from .player import AudioPlayer from .sound import Sound @@ -16,8 +31,23 @@ "AudioSystem", "AudioBuffer", "AudioBus", - "AudioChannel", "AudioDevice", + "AudioEffect", + "GainEffect", + "LimiterEffect", + "LowPassFilterEffect", + "HighPassFilterEffect", + "ParametricEQEffect", + "CompressorEffect", + "DelayEffect", + "ReverbEffect", + "DistortionEffect", + "NoiseGateEffect", + "StereoWidthEffect", + "AudioSend", + "AudioPreset", + "AudioPresetRegistry", + "create_builtin_presets", "AudioMixer", "MusicPlayer", "AudioPlayer", diff --git a/nexora/audio/audio.py b/nexora/audio/audio.py index 3b8dc0a..3e75fa7 100644 --- a/nexora/audio/audio.py +++ b/nexora/audio/audio.py @@ -2,10 +2,11 @@ from .bus import AudioBus from .cache import AudioCache -from .channel import AudioChannel from .device import AudioDevice +from .effects import AudioEffect from .mixer import AudioMixer from .player import AudioPlayer +from .preset import AudioPreset, AudioPresetRegistry from .sound import Sound from .stream import StreamedSound @@ -92,29 +93,11 @@ def __init__( AudioMixer() ) - self.mixer.add_bus( - AudioBus( - "Music" - ) - ) - - self.mixer.add_bus( - AudioBus( - "SFX" - ) - ) + # ====================================================== + # DSP presets + # ====================================================== - self.mixer.add_bus( - AudioBus( - "Ambient" - ) - ) - - self.mixer.add_bus( - AudioBus( - "Voice" - ) - ) + self.presets = AudioPresetRegistry(include_builtins=True) # ====================================================== # Cache @@ -260,40 +243,6 @@ def shutdown( self._initialized = False - # ========================================================== - # CHANNEL VOLUME - # ========================================================== - - def set_volume( - self, - channel: AudioChannel, - volume: float, - ) -> None: - self.mixer.set_volume( - channel, - volume, - ) - - def get_volume( - self, - channel: AudioChannel, - ) -> float: - return ( - self.mixer.get_volume( - channel - ) - ) - - def get_effective_volume( - self, - channel: AudioChannel, - ) -> float: - return ( - self.mixer.get_effective_volume( - channel - ) - ) - # ========================================================== # CACHE # ========================================================== @@ -345,6 +294,72 @@ def clear_cache( # BUSES # ========================================================== + def create_bus( + self, + name: str, + *, + parent: str | AudioBus | None = "Master", + volume: float = 1.0, + muted: bool = False, + pan: float = 0.0, + solo: bool = False, + bus_id: str | None = None, + ) -> AudioBus: + """Create and register a dynamic audio bus.""" + return self.mixer.create_bus( + name, + parent=parent, + volume=volume, + muted=muted, + pan=pan, + solo=solo, + bus_id=bus_id, + ) + + def has_bus(self, name: str) -> bool: + return self.mixer.has_bus(name) + + def get_buses(self) -> tuple[AudioBus, ...]: + return self.mixer.get_buses() + + def set_bus_parent( + self, + name: str, + parent: str | AudioBus | None, + ) -> None: + self.mixer.set_bus_parent(name, parent) + + def remove_bus( + self, + name: str, + *, + reassign_to: str | AudioBus | None = "Master", + reparent_children: bool = True, + ) -> AudioBus: + """Remove a bus and safely reassign sources using it.""" + removed = self.mixer.get_bus(name) + + if reassign_to is None: + replacement: AudioBus | None = self.mixer.master + elif isinstance(reassign_to, AudioBus): + replacement = self.mixer.get_bus(reassign_to.name) + else: + replacement = self.mixer.get_bus(reassign_to) + + for source in self.player.sources: + source_bus = source.bus + if source_bus is removed or ( + isinstance(source_bus, str) + and source_bus.casefold() in {removed.name.casefold(), removed.id.casefold()} + ): + source.bus = replacement + + child_target = replacement if reparent_children else removed.parent + return self.mixer.remove_bus( + name, + reparent_children_to=child_target, + ) + def get_bus( self, name: str, @@ -359,6 +374,29 @@ def get_bus( ) ) + def add_bus_effect( + self, + name: str, + effect: AudioEffect, + *, + index: int | None = None, + ) -> AudioEffect: + """Append/insert a DSP effect in a bus' ordered effect chain.""" + return self.mixer.get_bus(name).add_effect(effect, index=index) + + def remove_bus_effect( + self, + name: str, + effect: AudioEffect, + ) -> None: + self.mixer.get_bus(name).remove_effect(effect) + + def clear_bus_effects(self, name: str) -> None: + self.mixer.get_bus(name).clear_effects() + + def get_bus_effects(self, name: str) -> tuple[AudioEffect, ...]: + return self.mixer.get_bus(name).effects + def set_bus_volume( self, name: str, @@ -388,6 +426,48 @@ def get_bus_volume( ).volume ) + def set_bus_pan( + self, + name: str, + pan: float, + ) -> None: + """Set bus stereo balance from -1.0 (left) to +1.0 (right).""" + self.mixer.get_bus(name).pan = pan + + def get_bus_pan( + self, + name: str, + ) -> float: + return self.mixer.get_bus(name).pan + + def set_bus_solo( + self, + name: str, + solo: bool, + ) -> None: + """Enable or disable solo for a bus.""" + self.mixer.get_bus(name).solo = solo + + def get_bus_solo( + self, + name: str, + ) -> bool: + return bool(self.mixer.get_bus(name).solo) + + def get_bus_peak( + self, + name: str, + ) -> tuple[float, float]: + """Return the latest post-fader left/right peak meter values.""" + return self.mixer.get_bus(name).peak + + def get_bus_rms( + self, + name: str, + ) -> tuple[float, float]: + """Return the latest post-fader left/right RMS meter values.""" + return self.mixer.get_bus(name).rms + def set_bus_muted( self, name: str, @@ -417,6 +497,189 @@ def get_bus_muted( ).muted ) + def get_bus_by_id(self, bus_id: str) -> AudioBus: + return self.mixer.get_bus_by_id(bus_id) + + def rename_bus(self, name_or_id: str, new_name: str) -> AudioBus: + bus = self.mixer.get_bus(name_or_id) + old_name = bus.name + renamed = self.mixer.rename_bus(bus.id, new_name) + # Keep legacy string-routed sources valid after a rename. Sources that + # already store the AudioBus object need no changes because the id is stable. + for source in self.player.sources: + if isinstance(source.bus, str) and source.bus.casefold() == old_name.casefold(): + source.bus = renamed + return renamed + + # ========================================================== + # SEND / RETURN ROUTING + # ========================================================== + + def add_send( + self, + source: str | AudioBus, + target: str | AudioBus, + *, + amount: float = 1.0, + pre_fader: bool = False, + enabled: bool = True, + ): + return self.mixer.add_send( + source, target, amount=amount, pre_fader=pre_fader, enabled=enabled + ) + + def remove_send(self, send_or_id) -> None: + self.mixer.remove_send(send_or_id) + + def get_sends(self): + return self.mixer.sends + + def set_send_amount(self, send_id: str, amount: float) -> None: + self.mixer.set_send_amount(send_id, amount) + + def set_send_enabled(self, send_id: str, enabled: bool) -> None: + self.mixer.set_send_enabled(send_id, enabled) + + def set_send_pre_fader(self, send_id: str, pre_fader: bool) -> None: + self.mixer.set_send_pre_fader(send_id, pre_fader) + + # ========================================================== + # DSP CONTROL + # ========================================================== + + def set_bus_effects_bypassed(self, name: str, bypassed: bool) -> None: + self.mixer.get_bus(name).effects_bypassed = bypassed + + def set_bus_effect_bypassed(self, name: str, index: int, bypassed: bool) -> None: + self.mixer.get_bus(name).effects[int(index)].bypassed = bypassed + + def set_bus_effect_wet(self, name: str, index: int, wet: float) -> None: + self.mixer.get_bus(name).effects[int(index)].wet = wet + + def set_bus_effect_parameter( + self, name: str, index: int, parameter: str, value + ) -> None: + self.mixer.get_bus(name).effects[int(index)].set_parameter(parameter, value) + + def get_bus_effect_parameter(self, name: str, index: int, parameter: str): + return self.mixer.get_bus(name).effects[int(index)].get_parameter(parameter) + + # ========================================================== + # MASTER SAFETY / HEADROOM + # ========================================================== + + def set_headroom_db(self, value: float) -> None: + self.mixer.headroom_db = value + + def get_headroom_db(self) -> float: + return self.mixer.headroom_db + + def enable_master_limiter(self, threshold: float = 0.98): + return self.mixer.enable_master_limiter(threshold) + + def disable_master_limiter(self) -> None: + self.mixer.disable_master_limiter() + + # ========================================================== + # EXTENDED METERING + # ========================================================== + + def get_bus_peak_dbfs(self, name: str) -> tuple[float, float]: + return self.mixer.get_bus(name).peak_dbfs + + def get_bus_rms_dbfs(self, name: str) -> tuple[float, float]: + return self.mixer.get_bus(name).rms_dbfs + + def get_bus_peak_hold(self, name: str) -> tuple[float, float]: + return self.mixer.get_bus(name).peak_hold + + def get_bus_peak_hold_dbfs(self, name: str) -> tuple[float, float]: + return self.mixer.get_bus(name).peak_hold_dbfs + + def get_bus_clipped(self, name: str) -> bool: + return self.mixer.get_bus(name).clipped + + def clear_bus_peak_hold(self, name: str) -> None: + self.mixer.get_bus(name).clear_peak_hold() + + # ========================================================== + # DSP PRESETS + # ========================================================== + + def register_audio_preset( + self, + name: str, + effects, + *, + description: str = "", + overwrite: bool = False, + ) -> AudioPreset: + return self.presets.register_effects( + name, + effects, + description=description, + overwrite=overwrite, + ) + + def capture_bus_preset( + self, + name: str, + bus: str | AudioBus, + *, + description: str = "", + overwrite: bool = False, + ) -> AudioPreset: + target = bus if isinstance(bus, AudioBus) else self.mixer.get_bus(bus) + return self.presets.capture_bus( + name, + target, + description=description, + overwrite=overwrite, + ) + + def apply_audio_preset( + self, + preset: str, + bus: str | AudioBus, + *, + replace: bool = True, + ) -> tuple[AudioEffect, ...]: + target = bus if isinstance(bus, AudioBus) else self.mixer.get_bus(bus) + return self.presets.apply(preset, target, replace=replace) + + def get_audio_preset(self, name: str) -> AudioPreset: + return self.presets.get(name) + + def get_audio_presets(self) -> tuple[AudioPreset, ...]: + return self.presets.presets + + def remove_audio_preset(self, name: str) -> AudioPreset: + return self.presets.unregister(name) + + def save_audio_preset(self, name: str, path) -> None: + self.presets.save(name, path) + + def load_audio_preset( + self, + path, + *, + overwrite: bool = False, + name: str | None = None, + ) -> AudioPreset: + return self.presets.load(path, overwrite=overwrite, name=name) + + # ========================================================== + # MIXER SNAPSHOTS + # ========================================================== + + def create_mixer_snapshot(self) -> dict: + return self.mixer.create_snapshot() + + def restore_mixer_snapshot( + self, snapshot: dict, *, restore_topology: bool = True + ) -> None: + self.mixer.restore_snapshot(snapshot, restore_topology=restore_topology) + # ========================================================== # RESET # ========================================================== @@ -425,7 +688,7 @@ def reset( self, ) -> None: """ - Reset mixer channels and buses to their defaults. + Reset all registered audio buses to their defaults. """ self.mixer.reset() diff --git a/nexora/audio/bus.py b/nexora/audio/bus.py index d74a286..f31c99b 100644 --- a/nexora/audio/bus.py +++ b/nexora/audio/bus.py @@ -1,77 +1,270 @@ from __future__ import annotations +import math +from threading import RLock +from uuid import uuid4 + +import numpy as np + +from .effects import AudioEffect + + +def _linear_to_dbfs(value: float) -> float: + value = abs(float(value)) + if value <= 0.0: + return float("-inf") + return 20.0 * math.log10(value) + class AudioBus: - """Controls shared volume and mute state for a group of audio.""" + """A named node in Nexora's hierarchical audio bus graph.""" def __init__( self, name: str, volume: float = 1.0, muted: bool = False, + *, + parent: "AudioBus | None" = None, + pan: float = 0.0, + solo: bool = False, + bus_id: str | None = None, ) -> None: + name = str(name).strip() if not name: - raise ValueError( - "Audio bus name must not be empty." - ) + raise ValueError("Audio bus name must not be empty.") + self._id = str(bus_id or uuid4().hex) self.name = name self._volume = 1.0 self._muted = bool(muted) - + self._parent: AudioBus | None = None + self._pan = 0.0 + self._solo = bool(solo) + self._effects_bypassed = False + self._effects: list[AudioEffect] = [] + self._effects_lock = RLock() + self._effect_scratch = np.empty((0, 2), dtype=np.float32) + self._meter_lock = RLock() + self._peak = (0.0, 0.0) + self._rms = (0.0, 0.0) + self._peak_hold = (0.0, 0.0) + self._clipped = False self.volume = volume + self.pan = pan + self.parent = parent @property - def volume(self) -> float: - """Return the bus volume.""" + def id(self) -> str: + return self._id + @property + def volume(self) -> float: return self._volume @volume.setter def volume(self, value: float) -> None: - """Set the bus volume.""" - value = float(value) - if value < 0.0: - raise ValueError( - "Audio bus volume must not be negative." - ) - + raise ValueError("Audio bus volume must not be negative.") self._volume = value @property def muted(self) -> bool: - """Return whether the bus is muted.""" - return self._muted @muted.setter def muted(self, value: bool) -> None: - """Set the mute state.""" - self._muted = bool(value) @property - def effective_volume(self) -> float: - """Return the volume currently applied to the bus.""" + def pan(self) -> float: + return self._pan + + @pan.setter + def pan(self, value: float) -> None: + value = float(value) + if not -1.0 <= value <= 1.0: + raise ValueError("Audio bus pan must be between -1.0 and 1.0.") + self._pan = value + @property + def solo(self) -> bool: + return self._solo + + @solo.setter + def solo(self, value: bool) -> None: + self._solo = bool(value) + + @property + def effects_bypassed(self) -> bool: + return self._effects_bypassed + + @effects_bypassed.setter + def effects_bypassed(self, value: bool) -> None: + self._effects_bypassed = bool(value) + + @property + def peak(self) -> tuple[float, float]: + with self._meter_lock: + return self._peak + + @property + def rms(self) -> tuple[float, float]: + with self._meter_lock: + return self._rms + + @property + def peak_hold(self) -> tuple[float, float]: + with self._meter_lock: + return self._peak_hold + + @property + def clipped(self) -> bool: + with self._meter_lock: + return self._clipped + + @property + def peak_dbfs(self) -> tuple[float, float]: + left, right = self.peak + return _linear_to_dbfs(left), _linear_to_dbfs(right) + + @property + def rms_dbfs(self) -> tuple[float, float]: + left, right = self.rms + return _linear_to_dbfs(left), _linear_to_dbfs(right) + + @property + def peak_hold_dbfs(self) -> tuple[float, float]: + left, right = self.peak_hold + return _linear_to_dbfs(left), _linear_to_dbfs(right) + + def update_meter(self, buffer: np.ndarray) -> None: + if buffer.ndim != 2 or buffer.shape[0] == 0 or buffer.shape[1] < 2: + self.reset_meter(clear_hold=False) + return + + left = buffer[:, 0] + right = buffer[:, 1] + left_peak = max(abs(float(np.min(left))), abs(float(np.max(left)))) + right_peak = max(abs(float(np.min(right))), abs(float(np.max(right)))) + frames = float(buffer.shape[0]) + left_rms = float(np.sqrt(np.dot(left, left) / frames)) + right_rms = float(np.sqrt(np.dot(right, right) / frames)) + + with self._meter_lock: + self._peak = (left_peak, right_peak) + self._rms = (left_rms, right_rms) + self._peak_hold = ( + max(self._peak_hold[0], left_peak), + max(self._peak_hold[1], right_peak), + ) + self._clipped = self._clipped or left_peak >= 1.0 or right_peak >= 1.0 + + def reset_meter(self, *, clear_hold: bool = True) -> None: + with self._meter_lock: + self._peak = (0.0, 0.0) + self._rms = (0.0, 0.0) + if clear_hold: + self._peak_hold = (0.0, 0.0) + self._clipped = False + + def clear_peak_hold(self) -> None: + with self._meter_lock: + self._peak_hold = (0.0, 0.0) + self._clipped = False + + @property + def parent(self) -> "AudioBus | None": + return self._parent + + @parent.setter + def parent(self, value: "AudioBus | None") -> None: + if value is self: + raise ValueError("An audio bus cannot be its own parent.") + current = value + while current is not None: + if current is self: + raise ValueError("Audio bus hierarchy cannot contain cycles.") + current = current.parent + self._parent = value + + @property + def effective_volume(self) -> float: if self._muted: return 0.0 + volume = self._volume + current = self._parent + while current is not None: + if current.muted: + return 0.0 + volume *= current.volume + current = current.parent + return volume - return self._volume + @property + def effects(self) -> tuple[AudioEffect, ...]: + with self._effects_lock: + return tuple(self._effects) - def mute(self) -> None: - """Mute the audio bus.""" + def add_effect(self, effect: AudioEffect, *, index: int | None = None) -> AudioEffect: + if not isinstance(effect, AudioEffect): + raise TypeError("effect must inherit from AudioEffect.") + with self._effects_lock: + if effect in self._effects: + raise ValueError("Audio effect is already attached to this bus.") + if index is None: + self._effects.append(effect) + else: + self._effects.insert(int(index), effect) + return effect + + def remove_effect(self, effect: AudioEffect) -> None: + with self._effects_lock: + self._effects.remove(effect) + effect.reset() + + def clear_effects(self) -> None: + with self._effects_lock: + for effect in self._effects: + effect.reset() + self._effects.clear() + + def reset_effects(self) -> None: + with self._effects_lock: + for effect in self._effects: + effect.reset() + def process_effects(self, buffer: np.ndarray, *, sample_rate: int, channels: int) -> None: + if self._effects_bypassed: + return + with self._effects_lock: + if not self._effects: + return + if self._effect_scratch.shape[0] < buffer.shape[0] or self._effect_scratch.shape[1] != channels: + self._effect_scratch = np.empty((buffer.shape[0], channels), dtype=np.float32) + scratch = self._effect_scratch[: buffer.shape[0], :channels] + for effect in self._effects: + effect.process_mixed( + buffer, + scratch, + sample_rate=sample_rate, + channels=channels, + ) + + def mute(self) -> None: self._muted = True def unmute(self) -> None: - """Unmute the audio bus.""" - self._muted = False def toggle_mute(self) -> None: - """Toggle the mute state.""" + self._muted = not self._muted - self._muted = not self._muted \ No newline at end of file + def __repr__(self) -> str: + parent = self.parent.name if self.parent is not None else None + return ( + f"AudioBus(id={self.id!r}, name={self.name!r}, volume={self.volume!r}, " + f"muted={self.muted!r}, pan={self.pan!r}, solo={self.solo!r}, " + f"parent={parent!r}, effects={len(self._effects)})" + ) diff --git a/nexora/audio/effects.py b/nexora/audio/effects.py new file mode 100644 index 0000000..f518ff8 --- /dev/null +++ b/nexora/audio/effects.py @@ -0,0 +1,1003 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from threading import RLock +from typing import Any + +import numpy as np + + +class AudioEffect(ABC): + """Base class for in-place bus DSP effects. + + Effects expose a thread-safe bypass/wet-dry layer. The DSP implementation + itself still processes a fully-wet block in-place; :meth:`process_mixed` + handles the dry/wet blend without changing effect implementations. + """ + + effect_type = "effect" + + def __init__( + self, + *, + enabled: bool = True, + wet: float = 1.0, + ) -> None: + self._parameter_lock = RLock() + self._enabled = bool(enabled) + self._wet = 1.0 + self.wet = wet + + @property + def enabled(self) -> bool: + with self._parameter_lock: + return self._enabled + + @enabled.setter + def enabled(self, value: bool) -> None: + with self._parameter_lock: + self._enabled = bool(value) + + @property + def bypassed(self) -> bool: + return not self.enabled + + @bypassed.setter + def bypassed(self, value: bool) -> None: + self.enabled = not bool(value) + + @property + def wet(self) -> float: + with self._parameter_lock: + return self._wet + + @wet.setter + def wet(self, value: float) -> None: + value = float(value) + if not 0.0 <= value <= 1.0: + raise ValueError("Effect wet value must be between 0.0 and 1.0.") + with self._parameter_lock: + self._wet = value + + @abstractmethod + def process( + self, + buffer: np.ndarray, + *, + sample_rate: int, + channels: int, + ) -> None: + """Process a fully-wet audio block in-place.""" + + def process_mixed( + self, + buffer: np.ndarray, + scratch: np.ndarray, + *, + sample_rate: int, + channels: int, + ) -> None: + """Process bypass and wet/dry blending with reusable scratch memory.""" + with self._parameter_lock: + enabled = self._enabled + wet = self._wet + + if not enabled or wet <= 0.0: + return + if wet >= 1.0: + self.process(buffer, sample_rate=sample_rate, channels=channels) + return + + np.copyto(scratch, buffer) + self.process(scratch, sample_rate=sample_rate, channels=channels) + np.multiply(buffer, 1.0 - wet, out=buffer) + np.multiply(scratch, wet, out=scratch) + np.add(buffer, scratch, out=buffer) + + def set_parameter(self, name: str, value: Any) -> None: + """Set a public effect parameter through its normal property setter.""" + name = str(name).strip() + if not name or name.startswith("_") or not hasattr(type(self), name): + raise KeyError(f"Unknown effect parameter: {name}") + setattr(self, name, value) + + def get_parameter(self, name: str) -> Any: + name = str(name).strip() + if not name or name.startswith("_") or not hasattr(type(self), name): + raise KeyError(f"Unknown effect parameter: {name}") + return getattr(self, name) + + def reset(self) -> None: + """Reset state held across audio blocks, if any.""" + + def _parameter_state(self) -> dict[str, Any]: + return {} + + def to_state(self) -> dict[str, Any]: + return { + "type": self.effect_type, + "enabled": self.enabled, + "wet": self.wet, + "parameters": self._parameter_state(), + } + + +class GainEffect(AudioEffect): + """Simple linear gain stage useful for testing/custom bus chains.""" + + effect_type = "gain" + + def __init__( + self, + gain: float = 1.0, + *, + enabled: bool = True, + wet: float = 1.0, + ) -> None: + super().__init__(enabled=enabled, wet=wet) + self.gain = gain + + @property + def gain(self) -> float: + with self._parameter_lock: + return self._gain + + @gain.setter + def gain(self, value: float) -> None: + with self._parameter_lock: + self._gain = float(value) + + def process(self, buffer: np.ndarray, *, sample_rate: int, channels: int) -> None: + del sample_rate, channels + gain = self.gain + np.multiply(buffer, gain, out=buffer) + + def _parameter_state(self) -> dict[str, Any]: + return {"gain": self.gain} + + +class LimiterEffect(AudioEffect): + """Lightweight hard limiter for a bus or the master output.""" + + effect_type = "limiter" + + def __init__( + self, + threshold: float = 1.0, + *, + enabled: bool = True, + wet: float = 1.0, + ) -> None: + super().__init__(enabled=enabled, wet=wet) + self.threshold = threshold + + @property + def threshold(self) -> float: + with self._parameter_lock: + return self._threshold + + @threshold.setter + def threshold(self, value: float) -> None: + value = float(value) + if value <= 0.0: + raise ValueError("Limiter threshold must be greater than 0.") + with self._parameter_lock: + self._threshold = value + + def process(self, buffer: np.ndarray, *, sample_rate: int, channels: int) -> None: + del sample_rate, channels + threshold = self.threshold + np.clip(buffer, -threshold, threshold, out=buffer) + + def _parameter_state(self) -> dict[str, Any]: + return {"threshold": self.threshold} + + +def audio_effect_from_state(state: dict[str, Any]) -> AudioEffect: + """Recreate a built-in Nexora effect from snapshot state.""" + effect_type = str(state.get("type", "")).casefold() + enabled = bool(state.get("enabled", True)) + wet = float(state.get("wet", 1.0)) + parameters = dict(state.get("parameters") or {}) + + if effect_type == GainEffect.effect_type: + return GainEffect( + float(parameters.get("gain", 1.0)), enabled=enabled, wet=wet + ) + if effect_type == LimiterEffect.effect_type: + return LimiterEffect( + float(parameters.get("threshold", 1.0)), enabled=enabled, wet=wet + ) + if effect_type == LowPassFilterEffect.effect_type: + return LowPassFilterEffect(float(parameters.get("cutoff_hz", 12000.0)), float(parameters.get("q", 0.70710678)), enabled=enabled, wet=wet) + if effect_type == HighPassFilterEffect.effect_type: + return HighPassFilterEffect(float(parameters.get("cutoff_hz", 80.0)), float(parameters.get("q", 0.70710678)), enabled=enabled, wet=wet) + if effect_type == ParametricEQEffect.effect_type: + return ParametricEQEffect(float(parameters.get("frequency_hz", 1000.0)), float(parameters.get("gain_db", 0.0)), float(parameters.get("q", 1.0)), enabled=enabled, wet=wet) + if effect_type == CompressorEffect.effect_type: + return CompressorEffect(float(parameters.get("threshold_db", -12.0)), float(parameters.get("ratio", 4.0)), float(parameters.get("attack_ms", 10.0)), float(parameters.get("release_ms", 100.0)), float(parameters.get("makeup_gain_db", 0.0)), enabled=enabled, wet=wet) + if effect_type == DelayEffect.effect_type: + return DelayEffect(float(parameters.get("delay_seconds", 0.25)), float(parameters.get("feedback", 0.35)), enabled=enabled, wet=wet) + if effect_type == ReverbEffect.effect_type: + return ReverbEffect(float(parameters.get("room_size", 0.5)), float(parameters.get("damping", 0.35)), float(parameters.get("decay", 0.65)), enabled=enabled, wet=wet) + if effect_type == DistortionEffect.effect_type: + return DistortionEffect( + drive=float(parameters.get("drive", 2.0)), + output_gain_db=float(parameters.get("output_gain_db", 0.0)), + mode=str(parameters.get("mode", "soft")), + enabled=enabled, + wet=wet, + ) + if effect_type == NoiseGateEffect.effect_type: + return NoiseGateEffect( + threshold_db=float(parameters.get("threshold_db", -40.0)), + attack_ms=float(parameters.get("attack_ms", 2.0)), + hold_ms=float(parameters.get("hold_ms", 25.0)), + release_ms=float(parameters.get("release_ms", 80.0)), + enabled=enabled, + wet=wet, + ) + if effect_type == StereoWidthEffect.effect_type: + return StereoWidthEffect( + width=float(parameters.get("width", 1.0)), + enabled=enabled, + wet=wet, + ) + raise ValueError(f"Unknown built-in audio effect type: {effect_type!r}") + + +class _BiquadEffect(AudioEffect): + """Shared stateful Direct Form I biquad implementation.""" + + def __init__(self, *, enabled: bool = True, wet: float = 1.0) -> None: + super().__init__(enabled=enabled, wet=wet) + self._state_channels = 0 + self._x1 = np.zeros(0, dtype=np.float32) + self._x2 = np.zeros(0, dtype=np.float32) + self._y1 = np.zeros(0, dtype=np.float32) + self._y2 = np.zeros(0, dtype=np.float32) + + def _ensure_state(self, channels: int) -> None: + channels = max(1, int(channels)) + if channels == self._state_channels: + return + self._state_channels = channels + self._x1 = np.zeros(channels, dtype=np.float32) + self._x2 = np.zeros(channels, dtype=np.float32) + self._y1 = np.zeros(channels, dtype=np.float32) + self._y2 = np.zeros(channels, dtype=np.float32) + + def reset(self) -> None: + self._x1.fill(0.0) + self._x2.fill(0.0) + self._y1.fill(0.0) + self._y2.fill(0.0) + + def _coefficients(self, sample_rate: int) -> tuple[float, float, float, float, float]: + raise NotImplementedError + + def process(self, buffer: np.ndarray, *, sample_rate: int, channels: int) -> None: + if buffer.size == 0: + return + actual_channels = int(buffer.shape[1]) if buffer.ndim > 1 else 1 + self._ensure_state(actual_channels) + b0, b1, b2, a1, a2 = self._coefficients(sample_rate) + + x1 = self._x1 + x2 = self._x2 + y1 = self._y1 + y2 = self._y2 + frames = buffer if buffer.ndim > 1 else buffer.reshape(-1, 1) + + for i in range(frames.shape[0]): + x = frames[i].copy() + y = (b0 * x) + (b1 * x1) + (b2 * x2) - (a1 * y1) - (a2 * y2) + frames[i] = y + x2[:] = x1 + x1[:] = x + y2[:] = y1 + y1[:] = y + + +class LowPassFilterEffect(_BiquadEffect): + """Resonant low-pass filter implemented as a stateful biquad.""" + + effect_type = "lowpass" + + def __init__(self, cutoff_hz: float = 12_000.0, q: float = 0.70710678, *, enabled: bool = True, wet: float = 1.0) -> None: + super().__init__(enabled=enabled, wet=wet) + self.cutoff_hz = cutoff_hz + self.q = q + + @property + def cutoff_hz(self) -> float: + with self._parameter_lock: + return self._cutoff_hz + + @cutoff_hz.setter + def cutoff_hz(self, value: float) -> None: + value = float(value) + if value <= 0.0: + raise ValueError("Low-pass cutoff_hz must be greater than 0.") + with self._parameter_lock: + self._cutoff_hz = value + + @property + def q(self) -> float: + with self._parameter_lock: + return self._q + + @q.setter + def q(self, value: float) -> None: + value = float(value) + if value <= 0.0: + raise ValueError("Low-pass q must be greater than 0.") + with self._parameter_lock: + self._q = value + + def _coefficients(self, sample_rate: int): + cutoff = min(self.cutoff_hz, max(1.0, sample_rate * 0.499)) + q = self.q + w0 = 2.0 * np.pi * cutoff / float(sample_rate) + c = float(np.cos(w0)) + s = float(np.sin(w0)) + alpha = s / (2.0 * q) + b0 = (1.0 - c) * 0.5 + b1 = 1.0 - c + b2 = b0 + a0 = 1.0 + alpha + a1 = -2.0 * c + a2 = 1.0 - alpha + return b0 / a0, b1 / a0, b2 / a0, a1 / a0, a2 / a0 + + def _parameter_state(self) -> dict[str, Any]: + return {"cutoff_hz": self.cutoff_hz, "q": self.q} + + +class HighPassFilterEffect(_BiquadEffect): + """Resonant high-pass filter implemented as a stateful biquad.""" + + effect_type = "highpass" + + def __init__(self, cutoff_hz: float = 80.0, q: float = 0.70710678, *, enabled: bool = True, wet: float = 1.0) -> None: + super().__init__(enabled=enabled, wet=wet) + self.cutoff_hz = cutoff_hz + self.q = q + + @property + def cutoff_hz(self) -> float: + with self._parameter_lock: + return self._cutoff_hz + + @cutoff_hz.setter + def cutoff_hz(self, value: float) -> None: + value = float(value) + if value <= 0.0: + raise ValueError("High-pass cutoff_hz must be greater than 0.") + with self._parameter_lock: + self._cutoff_hz = value + + @property + def q(self) -> float: + with self._parameter_lock: + return self._q + + @q.setter + def q(self, value: float) -> None: + value = float(value) + if value <= 0.0: + raise ValueError("High-pass q must be greater than 0.") + with self._parameter_lock: + self._q = value + + def _coefficients(self, sample_rate: int): + cutoff = min(self.cutoff_hz, max(1.0, sample_rate * 0.499)) + q = self.q + w0 = 2.0 * np.pi * cutoff / float(sample_rate) + c = float(np.cos(w0)) + s = float(np.sin(w0)) + alpha = s / (2.0 * q) + b0 = (1.0 + c) * 0.5 + b1 = -(1.0 + c) + b2 = b0 + a0 = 1.0 + alpha + a1 = -2.0 * c + a2 = 1.0 - alpha + return b0 / a0, b1 / a0, b2 / a0, a1 / a0, a2 / a0 + + def _parameter_state(self) -> dict[str, Any]: + return {"cutoff_hz": self.cutoff_hz, "q": self.q} + + +class ParametricEQEffect(_BiquadEffect): + """Single peaking parametric EQ band.""" + + effect_type = "parametric_eq" + + def __init__(self, frequency_hz: float = 1_000.0, gain_db: float = 0.0, q: float = 1.0, *, enabled: bool = True, wet: float = 1.0) -> None: + super().__init__(enabled=enabled, wet=wet) + self.frequency_hz = frequency_hz + self.gain_db = gain_db + self.q = q + + @property + def frequency_hz(self) -> float: + with self._parameter_lock: + return self._frequency_hz + + @frequency_hz.setter + def frequency_hz(self, value: float) -> None: + value = float(value) + if value <= 0.0: + raise ValueError("EQ frequency_hz must be greater than 0.") + with self._parameter_lock: + self._frequency_hz = value + + @property + def gain_db(self) -> float: + with self._parameter_lock: + return self._gain_db + + @gain_db.setter + def gain_db(self, value: float) -> None: + with self._parameter_lock: + self._gain_db = float(value) + + @property + def q(self) -> float: + with self._parameter_lock: + return self._q + + @q.setter + def q(self, value: float) -> None: + value = float(value) + if value <= 0.0: + raise ValueError("EQ q must be greater than 0.") + with self._parameter_lock: + self._q = value + + def _coefficients(self, sample_rate: int): + frequency = min(self.frequency_hz, max(1.0, sample_rate * 0.499)) + q = self.q + gain_db = self.gain_db + a = 10.0 ** (gain_db / 40.0) + w0 = 2.0 * np.pi * frequency / float(sample_rate) + c = float(np.cos(w0)) + s = float(np.sin(w0)) + alpha = s / (2.0 * q) + b0 = 1.0 + alpha * a + b1 = -2.0 * c + b2 = 1.0 - alpha * a + a0 = 1.0 + alpha / a + a1 = -2.0 * c + a2 = 1.0 - alpha / a + return b0 / a0, b1 / a0, b2 / a0, a1 / a0, a2 / a0 + + def _parameter_state(self) -> dict[str, Any]: + return {"frequency_hz": self.frequency_hz, "gain_db": self.gain_db, "q": self.q} + + +class CompressorEffect(AudioEffect): + """Stereo-linked feed-forward compressor with attack/release smoothing.""" + + effect_type = "compressor" + + def __init__(self, threshold_db: float = -12.0, ratio: float = 4.0, attack_ms: float = 10.0, release_ms: float = 100.0, makeup_gain_db: float = 0.0, *, enabled: bool = True, wet: float = 1.0) -> None: + super().__init__(enabled=enabled, wet=wet) + self.threshold_db = threshold_db + self.ratio = ratio + self.attack_ms = attack_ms + self.release_ms = release_ms + self.makeup_gain_db = makeup_gain_db + self._gain_reduction_db = 0.0 + + @property + def threshold_db(self): + with self._parameter_lock: return self._threshold_db + @threshold_db.setter + def threshold_db(self, value): + with self._parameter_lock: self._threshold_db = float(value) + + @property + def ratio(self): + with self._parameter_lock: return self._ratio + @ratio.setter + def ratio(self, value): + value = float(value) + if value < 1.0: raise ValueError("Compressor ratio must be >= 1.0.") + with self._parameter_lock: self._ratio = value + + @property + def attack_ms(self): + with self._parameter_lock: return self._attack_ms + @attack_ms.setter + def attack_ms(self, value): + value = float(value) + if value < 0.0: raise ValueError("Compressor attack_ms must be >= 0.") + with self._parameter_lock: self._attack_ms = value + + @property + def release_ms(self): + with self._parameter_lock: return self._release_ms + @release_ms.setter + def release_ms(self, value): + value = float(value) + if value < 0.0: raise ValueError("Compressor release_ms must be >= 0.") + with self._parameter_lock: self._release_ms = value + + @property + def makeup_gain_db(self): + with self._parameter_lock: return self._makeup_gain_db + @makeup_gain_db.setter + def makeup_gain_db(self, value): + with self._parameter_lock: self._makeup_gain_db = float(value) + + @property + def gain_reduction_db(self) -> float: + return float(self._gain_reduction_db) + + def reset(self) -> None: + self._gain_reduction_db = 0.0 + + @staticmethod + def _time_coeff(ms: float, sample_rate: int) -> float: + if ms <= 0.0: return 0.0 + return float(np.exp(-1.0 / (0.001 * ms * sample_rate))) + + def process(self, buffer: np.ndarray, *, sample_rate: int, channels: int) -> None: + del channels + if buffer.size == 0: return + with self._parameter_lock: + threshold = self._threshold_db + ratio = self._ratio + attack = self._attack_ms + release = self._release_ms + makeup = self._makeup_gain_db + frames = buffer if buffer.ndim > 1 else buffer.reshape(-1, 1) + attack_c = self._time_coeff(attack, sample_rate) + release_c = self._time_coeff(release, sample_rate) + gr = float(self._gain_reduction_db) + makeup_lin = 10.0 ** (makeup / 20.0) + for i in range(frames.shape[0]): + peak = float(np.max(np.abs(frames[i]))) + level_db = 20.0 * np.log10(max(peak, 1e-12)) + target_gr = 0.0 + if level_db > threshold: + compressed_db = threshold + (level_db - threshold) / ratio + target_gr = compressed_db - level_db + coeff = attack_c if target_gr < gr else release_c + gr = coeff * gr + (1.0 - coeff) * target_gr + gain = (10.0 ** (gr / 20.0)) * makeup_lin + frames[i] *= gain + self._gain_reduction_db = gr + + def _parameter_state(self) -> dict[str, Any]: + return {"threshold_db": self.threshold_db, "ratio": self.ratio, "attack_ms": self.attack_ms, "release_ms": self.release_ms, "makeup_gain_db": self.makeup_gain_db} + + +class DelayEffect(AudioEffect): + """Feedback delay. The effect output is fully wet; use ``wet`` for dry/wet.""" + + effect_type = "delay" + + def __init__(self, delay_seconds: float = 0.25, feedback: float = 0.35, *, enabled: bool = True, wet: float = 0.25) -> None: + super().__init__(enabled=enabled, wet=wet) + self.delay_seconds = delay_seconds + self.feedback = feedback + self._delay_buffer = np.zeros((0, 0), dtype=np.float32) + self._delay_index = 0 + self._delay_signature = None + + @property + def delay_seconds(self): + with self._parameter_lock: return self._delay_seconds + @delay_seconds.setter + def delay_seconds(self, value): + value = float(value) + if value <= 0.0: raise ValueError("Delay delay_seconds must be > 0.") + with self._parameter_lock: self._delay_seconds = value + + @property + def feedback(self): + with self._parameter_lock: return self._feedback + @feedback.setter + def feedback(self, value): + value = float(value) + if not -0.99 <= value <= 0.99: raise ValueError("Delay feedback must be between -0.99 and 0.99.") + with self._parameter_lock: self._feedback = value + + def _ensure_delay(self, sample_rate: int, channels: int, delay_seconds: float) -> None: + length = max(1, int(round(delay_seconds * sample_rate))) + signature = (length, channels) + if signature == self._delay_signature: return + self._delay_buffer = np.zeros((length, channels), dtype=np.float32) + self._delay_index = 0 + self._delay_signature = signature + + def reset(self) -> None: + self._delay_buffer.fill(0.0) + self._delay_index = 0 + + def process(self, buffer: np.ndarray, *, sample_rate: int, channels: int) -> None: + if buffer.size == 0: return + frames = buffer if buffer.ndim > 1 else buffer.reshape(-1, 1) + actual_channels = frames.shape[1] + with self._parameter_lock: + delay_seconds = self._delay_seconds + feedback = self._feedback + self._ensure_delay(sample_rate, actual_channels, delay_seconds) + line = self._delay_buffer + idx = self._delay_index + n = line.shape[0] + for i in range(frames.shape[0]): + dry = frames[i].copy() + delayed = line[idx].copy() + frames[i] = delayed + line[idx] = dry + delayed * feedback + idx += 1 + if idx >= n: idx = 0 + self._delay_index = idx + + def _parameter_state(self) -> dict[str, Any]: + return {"delay_seconds": self.delay_seconds, "feedback": self.feedback} + + +class ReverbEffect(AudioEffect): + """Compact Schroeder-style reverb using parallel damped feedback combs.""" + + effect_type = "reverb" + _BASE_DELAYS = (0.0297, 0.0371, 0.0411, 0.0437) + + def __init__(self, room_size: float = 0.5, damping: float = 0.35, decay: float = 0.65, *, enabled: bool = True, wet: float = 0.2) -> None: + super().__init__(enabled=enabled, wet=wet) + self.room_size = room_size + self.damping = damping + self.decay = decay + self._lines: list[np.ndarray] = [] + self._indices: list[int] = [] + self._damped: list[np.ndarray] = [] + self._wet_sum = np.zeros(0, dtype=np.float32) + self._signature = None + + @property + def room_size(self): + with self._parameter_lock: return self._room_size + @room_size.setter + def room_size(self, value): + value = float(value) + if not 0.0 <= value <= 1.0: raise ValueError("Reverb room_size must be between 0 and 1.") + with self._parameter_lock: self._room_size = value + + @property + def damping(self): + with self._parameter_lock: return self._damping + @damping.setter + def damping(self, value): + value = float(value) + if not 0.0 <= value <= 1.0: raise ValueError("Reverb damping must be between 0 and 1.") + with self._parameter_lock: self._damping = value + + @property + def decay(self): + with self._parameter_lock: return self._decay + @decay.setter + def decay(self, value): + value = float(value) + if not 0.0 <= value < 1.0: raise ValueError("Reverb decay must be >= 0 and < 1.") + with self._parameter_lock: self._decay = value + + def _ensure_lines(self, sample_rate: int, channels: int, room_size: float) -> None: + scale = 0.65 + 0.7 * room_size + lengths = tuple(max(1, int(round(d * scale * sample_rate))) for d in self._BASE_DELAYS) + signature = (lengths, channels) + if signature == self._signature: return + self._lines = [np.zeros((n, channels), dtype=np.float32) for n in lengths] + self._indices = [0 for _ in lengths] + self._damped = [np.zeros(channels, dtype=np.float32) for _ in lengths] + self._wet_sum = np.zeros(channels, dtype=np.float32) + self._signature = signature + + def reset(self) -> None: + for line in self._lines: line.fill(0.0) + for damped in self._damped: damped.fill(0.0) + for i in range(len(self._indices)): self._indices[i] = 0 + + def process(self, buffer: np.ndarray, *, sample_rate: int, channels: int) -> None: + if buffer.size == 0: return + frames = buffer if buffer.ndim > 1 else buffer.reshape(-1, 1) + actual_channels = frames.shape[1] + with self._parameter_lock: + room = self._room_size + damping = self._damping + decay = self._decay + self._ensure_lines(sample_rate, actual_channels, room) + wet_sum = self._wet_sum + for frame_index in range(frames.shape[0]): + dry = frames[frame_index].copy() + wet_sum.fill(0.0) + for line_index, line in enumerate(self._lines): + idx = self._indices[line_index] + delayed = line[idx] + damped = self._damped[line_index] + damped *= damping + damped += delayed * (1.0 - damping) + wet_sum += delayed + line[idx] = dry + damped * decay + idx += 1 + if idx >= line.shape[0]: idx = 0 + self._indices[line_index] = idx + frames[frame_index] = wet_sum * (1.0 / len(self._lines)) + + def _parameter_state(self) -> dict[str, Any]: + return {"room_size": self.room_size, "damping": self.damping, "decay": self.decay} + +class DistortionEffect(AudioEffect): + """Non-linear distortion/saturation with soft or hard clipping. + + ``drive`` controls how strongly the signal is pushed into the transfer + function. ``output_gain_db`` is applied after distortion. The inherited + ``wet`` parameter provides parallel saturation without another buffer API. + """ + + effect_type = "distortion" + _VALID_MODES = {"soft", "hard"} + + def __init__( + self, + drive: float = 2.0, + output_gain_db: float = 0.0, + mode: str = "soft", + *, + enabled: bool = True, + wet: float = 1.0, + ) -> None: + super().__init__(enabled=enabled, wet=wet) + self.drive = drive + self.output_gain_db = output_gain_db + self.mode = mode + + @property + def drive(self) -> float: + with self._parameter_lock: + return self._drive + + @drive.setter + def drive(self, value: float) -> None: + value = float(value) + if value <= 0.0: + raise ValueError("Distortion drive must be greater than 0.") + with self._parameter_lock: + self._drive = value + + @property + def output_gain_db(self) -> float: + with self._parameter_lock: + return self._output_gain_db + + @output_gain_db.setter + def output_gain_db(self, value: float) -> None: + with self._parameter_lock: + self._output_gain_db = float(value) + + @property + def mode(self) -> str: + with self._parameter_lock: + return self._mode + + @mode.setter + def mode(self, value: str) -> None: + value = str(value).strip().casefold() + if value not in self._VALID_MODES: + raise ValueError("Distortion mode must be 'soft' or 'hard'.") + with self._parameter_lock: + self._mode = value + + def process(self, buffer: np.ndarray, *, sample_rate: int, channels: int) -> None: + del sample_rate, channels + if buffer.size == 0: + return + with self._parameter_lock: + drive = self._drive + output_gain = 10.0 ** (self._output_gain_db / 20.0) + mode = self._mode + + np.multiply(buffer, drive, out=buffer) + if mode == "soft": + # tanh is smooth around zero and approaches +/-1 asymptotically. + np.tanh(buffer, out=buffer) + else: + np.clip(buffer, -1.0, 1.0, out=buffer) + if output_gain != 1.0: + np.multiply(buffer, output_gain, out=buffer) + + def _parameter_state(self) -> dict[str, Any]: + return { + "drive": self.drive, + "output_gain_db": self.output_gain_db, + "mode": self.mode, + } + + +class NoiseGateEffect(AudioEffect): + """Stereo-linked noise gate with attack, hold and release smoothing.""" + + effect_type = "noise_gate" + + def __init__( + self, + threshold_db: float = -40.0, + attack_ms: float = 2.0, + hold_ms: float = 25.0, + release_ms: float = 80.0, + *, + enabled: bool = True, + wet: float = 1.0, + ) -> None: + super().__init__(enabled=enabled, wet=wet) + self.threshold_db = threshold_db + self.attack_ms = attack_ms + self.hold_ms = hold_ms + self.release_ms = release_ms + self._gain = 0.0 + self._hold_remaining = 0 + + @property + def threshold_db(self) -> float: + with self._parameter_lock: + return self._threshold_db + + @threshold_db.setter + def threshold_db(self, value: float) -> None: + with self._parameter_lock: + self._threshold_db = float(value) + + @property + def attack_ms(self) -> float: + with self._parameter_lock: + return self._attack_ms + + @attack_ms.setter + def attack_ms(self, value: float) -> None: + value = float(value) + if value < 0.0: + raise ValueError("Noise gate attack_ms must be >= 0.") + with self._parameter_lock: + self._attack_ms = value + + @property + def hold_ms(self) -> float: + with self._parameter_lock: + return self._hold_ms + + @hold_ms.setter + def hold_ms(self, value: float) -> None: + value = float(value) + if value < 0.0: + raise ValueError("Noise gate hold_ms must be >= 0.") + with self._parameter_lock: + self._hold_ms = value + + @property + def release_ms(self) -> float: + with self._parameter_lock: + return self._release_ms + + @release_ms.setter + def release_ms(self, value: float) -> None: + value = float(value) + if value < 0.0: + raise ValueError("Noise gate release_ms must be >= 0.") + with self._parameter_lock: + self._release_ms = value + + @property + def gain(self) -> float: + """Current smoothed gate gain, useful for meters/debugging.""" + return float(self._gain) + + @staticmethod + def _time_coeff(ms: float, sample_rate: int) -> float: + if ms <= 0.0: + return 0.0 + return float(np.exp(-1.0 / (0.001 * ms * sample_rate))) + + def reset(self) -> None: + self._gain = 0.0 + self._hold_remaining = 0 + + def process(self, buffer: np.ndarray, *, sample_rate: int, channels: int) -> None: + del channels + if buffer.size == 0: + return + frames = buffer if buffer.ndim > 1 else buffer.reshape(-1, 1) + with self._parameter_lock: + threshold_linear = 10.0 ** (self._threshold_db / 20.0) + attack_ms = self._attack_ms + hold_samples = max(0, int(round(self._hold_ms * 0.001 * sample_rate))) + release_ms = self._release_ms + + attack_c = self._time_coeff(attack_ms, sample_rate) + release_c = self._time_coeff(release_ms, sample_rate) + gain = float(self._gain) + hold_remaining = int(self._hold_remaining) + + for i in range(frames.shape[0]): + peak = float(np.max(np.abs(frames[i]))) + if peak >= threshold_linear: + target = 1.0 + hold_remaining = hold_samples + elif hold_remaining > 0: + target = 1.0 + hold_remaining -= 1 + else: + target = 0.0 + + coeff = attack_c if target > gain else release_c + gain = coeff * gain + (1.0 - coeff) * target + frames[i] *= gain + + self._gain = gain + self._hold_remaining = hold_remaining + + def _parameter_state(self) -> dict[str, Any]: + return { + "threshold_db": self.threshold_db, + "attack_ms": self.attack_ms, + "hold_ms": self.hold_ms, + "release_ms": self.release_ms, + } + + +class StereoWidthEffect(AudioEffect): + """Mid/side stereo-width processor. + + ``width=0`` collapses to mono, ``1`` is neutral and ``2`` doubles the side + component. Mono buffers are intentionally left unchanged. + """ + + effect_type = "stereo_width" + + def __init__( + self, + width: float = 1.0, + *, + enabled: bool = True, + wet: float = 1.0, + ) -> None: + super().__init__(enabled=enabled, wet=wet) + self.width = width + + @property + def width(self) -> float: + with self._parameter_lock: + return self._width + + @width.setter + def width(self, value: float) -> None: + value = float(value) + if not 0.0 <= value <= 2.0: + raise ValueError("Stereo width must be between 0.0 and 2.0.") + with self._parameter_lock: + self._width = value + + def process(self, buffer: np.ndarray, *, sample_rate: int, channels: int) -> None: + del sample_rate, channels + if buffer.size == 0 or buffer.ndim < 2 or buffer.shape[1] < 2: + return + width = self.width + if width == 1.0: + return + + left = buffer[:, 0] + right = buffer[:, 1] + # Copy one temporary side vector so in-place writes cannot corrupt the + # right-channel input. This allocation is one vector per processed + # block; bus-level wet/dry scratch remains reusable in the mixer. + mid = (left + right) * 0.5 + side = (left - right) * (0.5 * width) + np.add(mid, side, out=left) + np.subtract(mid, side, out=right) + + def _parameter_state(self) -> dict[str, Any]: + return {"width": self.width} + diff --git a/nexora/audio/mixer.py b/nexora/audio/mixer.py index d9d25eb..f921bf0 100644 --- a/nexora/audio/mixer.py +++ b/nexora/audio/mixer.py @@ -1,127 +1,506 @@ from __future__ import annotations +import math +from copy import deepcopy +from typing import Any + from .bus import AudioBus -from .channel import AudioChannel +from .effects import LimiterEffect, audio_effect_from_state +from .send import AudioSend class AudioMixer: - """Controls Nexora's audio channel volumes and audio buses.""" + """Owns the dynamic audio graph, routing, sends and mixer snapshots.""" - def __init__(self) -> None: - self._volumes: dict[AudioChannel, float] = { - AudioChannel.MASTER: 1.0, - AudioChannel.MUSIC: 1.0, - AudioChannel.SFX: 1.0, - AudioChannel.AMBIENT: 1.0, - AudioChannel.VOICE: 1.0, - } + SNAPSHOT_VERSION = 1 + def __init__(self) -> None: self._buses: dict[str, AudioBus] = {} + self._buses_by_id: dict[str, AudioBus] = {} + self._sends: dict[str, AudioSend] = {} + self._headroom_db = 0.0 + self._master_limiter: LimiterEffect | None = None - self.add_bus( - AudioBus("Master") - ) + self.add_bus(AudioBus("Master")) + for name in ("Music", "SFX", "Ambient", "Voice"): + self.create_bus(name, parent="Master") + + @staticmethod + def _key(name: str) -> str: + key = str(name).strip().casefold() + if not key: + raise ValueError("Audio bus name must not be empty.") + return key @property def buses(self) -> tuple[AudioBus, ...]: - """Return all registered audio buses.""" - return tuple(self._buses.values()) + @property + def sends(self) -> tuple[AudioSend, ...]: + return tuple(self._sends.values()) + @property def master(self) -> AudioBus: - """Return the master audio bus.""" + return self.get_bus("Master") - return self._buses["Master"] + @property + def headroom_db(self) -> float: + return self._headroom_db - def add_bus( - self, - bus: AudioBus, - ) -> None: - """Register an audio bus.""" + @headroom_db.setter + def headroom_db(self, value: float) -> None: + value = float(value) + if value > 0.0: + raise ValueError("Mixer headroom_db must be <= 0.0 dB.") + self._headroom_db = value - if bus.name in self._buses: - raise ValueError( - f"Audio bus already exists: {bus.name}" - ) + @property + def headroom_gain(self) -> float: + return math.pow(10.0, self._headroom_db / 20.0) - self._buses[bus.name] = bus + @property + def master_limiter_enabled(self) -> bool: + return self._master_limiter is not None and self._master_limiter.enabled - def remove_bus( - self, - name: str, - ) -> None: - """Remove an audio bus.""" + @property + def master_limiter(self) -> LimiterEffect | None: + return self._master_limiter - if name == "Master": - raise ValueError( - "The master audio bus cannot be removed." - ) + def enable_master_limiter(self, threshold: float = 0.98) -> LimiterEffect: + if self._master_limiter is None: + self._master_limiter = LimiterEffect(threshold) + else: + self._master_limiter.threshold = threshold + self._master_limiter.enabled = True + return self._master_limiter - if name not in self._buses: - raise KeyError( - f"Unknown audio bus: {name}" - ) + def disable_master_limiter(self) -> None: + if self._master_limiter is not None: + self._master_limiter.enabled = False - del self._buses[name] + def has_bus(self, name_or_id: str) -> bool: + value = str(name_or_id) + return value in self._buses_by_id or self._key(value) in self._buses - def get_bus( + def add_bus(self, bus: AudioBus) -> AudioBus: + if not isinstance(bus, AudioBus): + raise TypeError("bus must be an AudioBus.") + key = self._key(bus.name) + if key in self._buses: + raise ValueError(f"Audio bus already exists: {bus.name}") + if bus.id in self._buses_by_id: + raise ValueError(f"Audio bus id already exists: {bus.id}") + if bus.parent is not None and bus.parent.id not in self._buses_by_id: + raise ValueError("Parent audio bus must be registered first.") + self._buses[key] = bus + self._buses_by_id[bus.id] = bus + return bus + + def create_bus( self, name: str, + *, + parent: str | AudioBus | None = "Master", + volume: float = 1.0, + muted: bool = False, + pan: float = 0.0, + solo: bool = False, + bus_id: str | None = None, ) -> AudioBus: - """Return an audio bus by name.""" + parent_bus = self.resolve_optional_bus(parent) + bus = AudioBus( + name, + volume=volume, + muted=muted, + parent=parent_bus, + pan=pan, + solo=solo, + bus_id=bus_id, + ) + return self.add_bus(bus) + + def resolve_optional_bus(self, bus: AudioBus | str | None) -> AudioBus | None: + if bus is None: + return None + if isinstance(bus, AudioBus): + if bus.id in self._buses_by_id: + return self._buses_by_id[bus.id] + return self.get_bus(bus.name) + return self.get_bus(bus) + def resolve_bus(self, bus: AudioBus | str | None) -> AudioBus: + return self.resolve_optional_bus(bus) or self.master + + def get_bus(self, name_or_id: str) -> AudioBus: + value = str(name_or_id) + by_id = self._buses_by_id.get(value) + if by_id is not None: + return by_id try: - return self._buses[name] + return self._buses[self._key(value)] except KeyError: - raise KeyError( - f"Unknown audio bus: {name}" - ) from None + raise KeyError(f"Unknown audio bus: {name_or_id}") from None - def set_volume( - self, - channel: AudioChannel, - volume: float, - ) -> None: - """Set the volume of an audio channel.""" - - if not 0.0 <= volume <= 1.0: - raise ValueError( - "Audio volume must be between 0.0 and 1.0." - ) + def get_bus_by_id(self, bus_id: str) -> AudioBus: + try: + return self._buses_by_id[str(bus_id)] + except KeyError: + raise KeyError(f"Unknown audio bus id: {bus_id}") from None - self._volumes[channel] = volume + def get_buses(self) -> tuple[AudioBus, ...]: + return self.buses - def get_volume( + def rename_bus(self, name_or_id: str, new_name: str) -> AudioBus: + bus = self.get_bus(name_or_id) + if bus is self.master: + raise ValueError("The master audio bus cannot be renamed.") + new_name = str(new_name).strip() + new_key = self._key(new_name) + old_key = self._key(bus.name) + if new_key != old_key and new_key in self._buses: + raise ValueError(f"Audio bus already exists: {new_name}") + del self._buses[old_key] + bus.name = new_name + self._buses[new_key] = bus + return bus + + def set_bus_parent(self, name_or_id: str, parent: str | AudioBus | None) -> None: + bus = self.get_bus(name_or_id) + if bus is self.master: + if parent is not None: + raise ValueError("The master audio bus cannot have a parent.") + return + old_parent = bus.parent + bus.parent = self.resolve_optional_bus(parent) + try: + self._validate_routing_graph() + except Exception: + bus.parent = old_parent + raise + + def remove_bus( self, - channel: AudioChannel, - ) -> float: - """Return the volume of an audio channel.""" + name_or_id: str, + *, + reparent_children_to: str | AudioBus | None = "Master", + ) -> AudioBus: + bus = self.get_bus(name_or_id) + if bus is self.master: + raise ValueError("The master audio bus cannot be removed.") + + replacement = self.resolve_optional_bus(reparent_children_to) + if replacement is bus: + replacement = bus.parent + + for child in self._buses.values(): + if child.parent is bus: + child.parent = replacement - return self._volumes[channel] + for send_id, send in tuple(self._sends.items()): + if send.source_bus_id == bus.id or send.target_bus_id == bus.id: + del self._sends[send_id] - def get_effective_volume( + del self._buses[self._key(bus.name)] + del self._buses_by_id[bus.id] + self._validate_routing_graph() + return bus + + # ------------------------------------------------------------------ + # Sends / returns + # ------------------------------------------------------------------ + + def add_send( self, - channel: AudioChannel, - ) -> float: - """Return the effective volume of an audio channel.""" - - if channel == AudioChannel.MASTER: - return self._volumes[ - AudioChannel.MASTER - ] - - return ( - self._volumes[AudioChannel.MASTER] - * self._volumes[channel] + source: str | AudioBus, + target: str | AudioBus, + *, + amount: float = 1.0, + pre_fader: bool = False, + enabled: bool = True, + send_id: str | None = None, + ) -> AudioSend: + source_bus = self.resolve_bus(source) + target_bus = self.resolve_bus(target) + if source_bus is target_bus: + raise ValueError("An audio bus cannot send to itself.") + send = AudioSend( + source_bus.id, + target_bus.id, + amount=amount, + pre_fader=pre_fader, + enabled=enabled, + **({"id": str(send_id)} if send_id else {}), ) + self._sends[send.id] = send + try: + self._validate_routing_graph() + except Exception: + del self._sends[send.id] + raise + return send - def reset(self) -> None: - """Reset all audio channel volumes.""" + def remove_send(self, send_or_id: AudioSend | str) -> None: + send_id = send_or_id.id if isinstance(send_or_id, AudioSend) else str(send_or_id) + if send_id not in self._sends: + raise KeyError(f"Unknown audio send: {send_id}") + del self._sends[send_id] - for channel in self._volumes: - self._volumes[channel] = 1.0 + def get_send(self, send_id: str) -> AudioSend: + try: + return self._sends[str(send_id)] + except KeyError: + raise KeyError(f"Unknown audio send: {send_id}") from None + + def get_sends_from(self, bus: str | AudioBus) -> tuple[AudioSend, ...]: + bus_id = self.resolve_bus(bus).id + return tuple(send for send in self._sends.values() if send.source_bus_id == bus_id) + + def set_send_amount(self, send_id: str, amount: float) -> None: + self.get_send(send_id).amount = amount + + def set_send_enabled(self, send_id: str, enabled: bool) -> None: + send = self.get_send(send_id) + old = send.enabled + send.enabled = bool(enabled) + try: + self._validate_routing_graph() + except Exception: + send.enabled = old + raise + + def set_send_pre_fader(self, send_id: str, pre_fader: bool) -> None: + self.get_send(send_id).pre_fader = bool(pre_fader) + + # ------------------------------------------------------------------ + # Graph ordering / solo + # ------------------------------------------------------------------ + + def _routing_edges(self) -> dict[str, set[str]]: + edges = {bus.id: set() for bus in self._buses.values()} + for bus in self._buses.values(): + if bus is self.master: + continue + target = bus.parent or self.master + edges[bus.id].add(target.id) + for send in self._sends.values(): + if send.enabled: + edges[send.source_bus_id].add(send.target_bus_id) + return edges + + def _validate_routing_graph(self) -> None: + self.processing_order() + + def processing_order(self) -> tuple[AudioBus, ...]: + """Return source-before-destination topological routing order.""" + edges = self._routing_edges() + indegree = {bus_id: 0 for bus_id in edges} + for targets in edges.values(): + for target in targets: + indegree[target] += 1 + + queue = [bus.id for bus in self._buses.values() if indegree[bus.id] == 0] + ordered: list[AudioBus] = [] + while queue: + bus_id = queue.pop(0) + ordered.append(self._buses_by_id[bus_id]) + for target in edges[bus_id]: + indegree[target] -= 1 + if indegree[target] == 0: + queue.append(target) + + if len(ordered) != len(self._buses): + raise ValueError("Audio routing graph cannot contain feedback cycles.") + return tuple(ordered) + + def buses_by_depth(self, *, deepest_first: bool = False) -> tuple[AudioBus, ...]: + ordered = self.processing_order() + return ordered if deepest_first else tuple(reversed(ordered)) + + @property + def solo_active(self) -> bool: + return any(bus.solo for bus in self._buses.values()) + + @staticmethod + def _is_ancestor(ancestor: AudioBus, bus: AudioBus) -> bool: + current = bus.parent + while current is not None: + if current is ancestor: + return True + current = current.parent + return False + + def is_bus_audible(self, bus: AudioBus) -> bool: + soloed = tuple(candidate for candidate in self._buses.values() if candidate.solo) + if not soloed: + return True + # Preserve the established solo behaviour for the full hierarchy branch + # and additionally keep downstream send/return targets alive. Otherwise + # soloing Weapons would silence the Reverb return fed by Weapons. + audible_ids: set[str] = set() + for candidate in soloed: + for branch_bus in self._buses.values(): + if ( + branch_bus is candidate + or self._is_ancestor(branch_bus, candidate) + or self._is_ancestor(candidate, branch_bus) + ): + audible_ids.add(branch_bus.id) + + edges = self._routing_edges() + queue = list(audible_ids) + while queue: + source_id = queue.pop() + for target_id in edges[source_id]: + if target_id not in audible_ids: + audible_ids.add(target_id) + queue.append(target_id) + + return bus.id in audible_ids + + # ------------------------------------------------------------------ + # Snapshots + # ------------------------------------------------------------------ + + def create_snapshot(self) -> dict[str, Any]: + return deepcopy({ + "version": self.SNAPSHOT_VERSION, + "headroom_db": self.headroom_db, + "master_limiter": None if self._master_limiter is None else self._master_limiter.to_state(), + "buses": [ + { + "id": bus.id, + "name": bus.name, + "parent_id": None if bus.parent is None else bus.parent.id, + "volume": bus.volume, + "muted": bus.muted, + "pan": bus.pan, + "solo": bus.solo, + "effects_bypassed": bus.effects_bypassed, + "effects": [effect.to_state() for effect in bus.effects], + } + for bus in self._buses.values() + ], + "sends": [ + { + "id": send.id, + "source_bus_id": send.source_bus_id, + "target_bus_id": send.target_bus_id, + "amount": send.amount, + "pre_fader": send.pre_fader, + "enabled": send.enabled, + } + for send in self._sends.values() + ], + }) + + def restore_snapshot(self, snapshot: dict[str, Any], *, restore_topology: bool = True) -> None: + data = deepcopy(snapshot) + if int(data.get("version", 0)) != self.SNAPSHOT_VERSION: + raise ValueError("Unsupported audio mixer snapshot version.") + + bus_states = list(data.get("buses") or []) + if not bus_states: + raise ValueError("Mixer snapshot contains no buses.") + + master_state = next((state for state in bus_states if str(state.get("name", "")).casefold() == "master"), None) + if master_state is None: + raise ValueError("Mixer snapshot must contain Master bus.") + + snapshot_to_runtime: dict[str, str] = {str(master_state["id"]): self.master.id} + + if restore_topology: + wanted_ids = {str(state["id"]) for state in bus_states if state is not master_state} + for bus in tuple(self._buses.values()): + if bus is not self.master and bus.id not in wanted_ids: + self.remove_bus(bus.id) + + # First pass: make sure all buses exist and names are restored. + for state in bus_states: + snapshot_id = str(state["id"]) + if state is master_state: + bus = self.master + elif snapshot_id in self._buses_by_id: + bus = self._buses_by_id[snapshot_id] + elif restore_topology: + bus = self.create_bus(str(state["name"]), parent=None, bus_id=snapshot_id) + else: + try: + bus = self.get_bus(str(state["name"])) + except KeyError: + continue + snapshot_to_runtime[snapshot_id] = bus.id + if bus.name != str(state["name"]): + self.rename_bus(bus.id, str(state["name"])) + + # Parent relations after all buses exist. + for state in bus_states: + snapshot_id = str(state["id"]) + runtime_id = snapshot_to_runtime.get(snapshot_id) + if runtime_id is None: + continue + bus = self.get_bus_by_id(runtime_id) + if bus is self.master: + bus.parent = None + else: + parent_snapshot_id = state.get("parent_id") + if parent_snapshot_id is None: + bus.parent = None + else: + parent_runtime_id = snapshot_to_runtime[str(parent_snapshot_id)] + bus.parent = self.get_bus_by_id(parent_runtime_id) + + bus.volume = float(state.get("volume", 1.0)) + bus.muted = bool(state.get("muted", False)) + bus.pan = float(state.get("pan", 0.0)) + bus.solo = bool(state.get("solo", False)) + bus.effects_bypassed = bool(state.get("effects_bypassed", False)) + bus.clear_effects() + for effect_state in state.get("effects") or []: + try: + bus.add_effect(audio_effect_from_state(effect_state)) + except ValueError: + # Custom effect types cannot be recreated safely without a + # registered factory. Built-in Nexora effects are complete. + continue + + self._sends.clear() + for state in data.get("sends") or []: + source_id = snapshot_to_runtime.get(str(state["source_bus_id"])) + target_id = snapshot_to_runtime.get(str(state["target_bus_id"])) + if source_id is None or target_id is None: + continue + self.add_send( + source_id, + target_id, + amount=float(state.get("amount", 1.0)), + pre_fader=bool(state.get("pre_fader", False)), + enabled=bool(state.get("enabled", True)), + send_id=str(state["id"]), + ) + + self.headroom_db = float(data.get("headroom_db", 0.0)) + limiter_state = data.get("master_limiter") + if limiter_state is None: + self._master_limiter = None + else: + effect = audio_effect_from_state(limiter_state) + if not isinstance(effect, LimiterEffect): + raise ValueError("Snapshot master_limiter is not a limiter.") + self._master_limiter = effect + + self._validate_routing_graph() + + def reset(self) -> None: + self._sends.clear() + self.headroom_db = 0.0 + self._master_limiter = None for bus in self._buses.values(): bus.volume = 1.0 - bus.muted = False \ No newline at end of file + bus.muted = False + bus.pan = 0.0 + bus.solo = False + bus.effects_bypassed = False + bus.reset_meter() + bus.reset_effects() diff --git a/nexora/audio/music.py b/nexora/audio/music.py index 259c02a..d5fc971 100644 --- a/nexora/audio/music.py +++ b/nexora/audio/music.py @@ -5,7 +5,6 @@ from enum import Enum from typing import TYPE_CHECKING -from .channel import AudioChannel from .sound import Sound from .source import AudioSource, AudioSourceState @@ -109,7 +108,6 @@ def _play( source = AudioSource( sound, - channel=AudioChannel.MUSIC, bus=self.audio.mixer.get_bus("Music"), ) @@ -155,7 +153,6 @@ def previous(self) -> bool: source = AudioSource( previous, - channel=AudioChannel.MUSIC, bus=self.audio.mixer.get_bus("Music"), ) diff --git a/nexora/audio/pcm.py b/nexora/audio/pcm.py index de9a67b..02cf1db 100644 --- a/nexora/audio/pcm.py +++ b/nexora/audio/pcm.py @@ -1,138 +1,94 @@ from __future__ import annotations from array import array -from collections.abc import Iterable -import struct import sys +import numpy as np + from .buffer import AudioBuffer -PcmSamples = list[float] | array +PcmSamples = np.ndarray + -# Python lists store one pointer plus a separate Python float object for every -# sample. That becomes extremely expensive for music tracks. Keep small -# clips as lists for backwards compatibility, but use a packed float array for -# anything larger than a short audio event. -_COMPACT_SAMPLE_THRESHOLD = 4096 +def _decode_24bit_numpy(data: bytes) -> np.ndarray: + """Decode little-endian signed 24-bit PCM into contiguous float32.""" + if not data: + return np.empty(0, dtype=np.float32) -def _compact(values: Iterable[float]) -> array: - return array("f", values) + raw = np.frombuffer(data, dtype=np.uint8) + usable = (raw.size // 3) * 3 + if usable == 0: + return np.empty(0, dtype=np.float32) + triplets = raw[:usable].reshape(-1, 3) + values = ( + triplets[:, 0].astype(np.int32) + | (triplets[:, 1].astype(np.int32) << 8) + | (triplets[:, 2].astype(np.int32) << 16) + ) -def _decode_24bit_samples(data: bytes) -> Iterable[float]: - for index in range(0, len(data), 3): - raw = int.from_bytes( - data[index:index + 3], - byteorder="little", - signed=True, - ) - yield raw / 8388608.0 + # Sign-extend bit 23 without a Python loop. + values = (values ^ 0x00800000) - 0x00800000 + return values.astype(np.float32) * np.float32(1.0 / 8388608.0) def decode_pcm(buffer: AudioBuffer) -> PcmSamples: - """Decode PCM audio into normalized samples without a large object spike.""" + """Decode integer PCM into contiguous normalized float32 samples. + + The returned NumPy array is the canonical in-memory representation used by + the mixer. Decoding is vectorized so loading a large clip does not create + one Python object per sample. + """ data = buffer.data - width = buffer.bytes_per_sample + width = int(buffer.bytes_per_sample) - if width == 1: - if len(data) > _COMPACT_SAMPLE_THRESHOLD: - return _compact( - (sample - 128) / 128.0 - for sample in data - ) + if not data: + return np.empty(0, dtype=np.float32) - return [ - (sample - 128) / 128.0 - for sample in data - ] + if width == 1: + values = np.frombuffer(data, dtype=np.uint8) + result = values.astype(np.float32) + result -= np.float32(128.0) + result *= np.float32(1.0 / 128.0) + return result if width == 2: - count = len(data) // 2 - - if not count: - return [] - - if count > _COMPACT_SAMPLE_THRESHOLD: - return _compact( - value / 32768.0 - for (value,) in struct.iter_unpack( - " _COMPACT_SAMPLE_THRESHOLD: - return _compact( - _decode_24bit_samples(data) - ) - - samples: list[float] = [] - - for index in range(0, len(data), 3): - raw = int.from_bytes( - data[index:index + 3], - byteorder="little", - signed=True, - ) - - samples.append(raw / 8388608.0) - - return samples + return _decode_24bit_numpy(data) if width == 4: - count = len(data) // 4 - - if not count: - return [] - - if count > _COMPACT_SAMPLE_THRESHOLD: - return _compact( - value / 2147483648.0 - for (value,) in struct.iter_unpack( - " bytes: - """Encode normalized samples as little-endian float32. - Using a packed array avoids the large temporary argument tuple created by - ``struct.pack(*samples)`` on every audio update. - """ +def encode_float32(samples: list[float] | array | np.ndarray) -> bytes: + """Encode normalized samples as contiguous little-endian float32.""" - if not samples: + if len(samples) == 0: return b"" + if isinstance(samples, np.ndarray): + if ( + samples.dtype == np.dtype(" None: - if source not in self._sources: - self._sources.append( - source - ) + with self._sources_lock: + if source not in self._sources: + self._sources.append( + source + ) def remove( self, source: AudioSource, ) -> None: - if source in self._sources: - self._sources.remove( - source - ) + with self._sources_lock: + if source in self._sources: + self._sources.remove( + source + ) - source._close_stream_reader() + source._close_stream_reader() # ========================================================== # PLAYBACK CONTROL @@ -214,36 +237,72 @@ def play( self, source: AudioSource, ) -> None: - if source not in self._sources: - self.add( - source - ) - - source.play() + with self._sources_lock: + if source not in self._sources: + self._sources.append(source) + source.play() def pause( self, source: AudioSource, ) -> None: - source.pause() + with self._sources_lock: + source.pause() def resume( self, source: AudioSource, ) -> None: - source.resume() + with self._sources_lock: + source.resume() def stop( self, source: AudioSource, ) -> None: - source.stop() + with self._sources_lock: + source.stop() def stop_all( self, ) -> None: + with self._sources_lock: + for source in self._sources: + source.stop() + self._prune_stopped_sources_locked() + + def _prune_stopped_sources_locked(self) -> int: + """Drop stopped sources while ``_sources_lock`` is already held. + + Audio sources are short-lived runtime objects. Keeping naturally + finished or explicitly stopped sources in the player retains their + Sound/StreamedSound references and, for streams, can also retain a + decoded NumPy window. This helper makes source ownership bounded. + Paused sources are intentionally kept. + """ + + if not self._sources: + return 0 + + survivors: list[AudioSource] = [] + removed = 0 for source in self._sources: - source.stop() + if source.state == AudioSourceState.STOPPED: + source._close_stream_reader() + removed += 1 + else: + survivors.append(source) + + if removed: + self._sources[:] = survivors + + return removed + + def prune_stopped_sources(self) -> int: + """Remove finished/stopped sources and return the number removed.""" + + with self._sources_lock: + return self._prune_stopped_sources_locked() # ========================================================== # ACTIVE SOURCES @@ -253,11 +312,12 @@ def stop_all( def has_playing_sources( self, ) -> bool: - return any( - source.state - == AudioSourceState.PLAYING - for source in self._sources - ) + with self._sources_lock: + return any( + source.state + == AudioSourceState.PLAYING + for source in self._sources + ) # ========================================================== # MIX @@ -288,33 +348,168 @@ def mix( "stereo output only." ) - mixed = [ - 0.0 - ] * ( - frame_count - * self.output_channels - ) - - for source in self._sources: - if ( - source.state - != AudioSourceState.PLAYING - ): - continue + sample_count = frame_count * self.output_channels + if sample_count > self._mix_buffer.size: + # Grow geometrically so an occasional larger request does not + # cause repeated reallocations on subsequent updates. + new_size = max(sample_count, self._mix_buffer.size * 2) + self._mix_buffer = np.zeros(new_size, dtype=np.float32) + + mixed = self._mix_buffer[:sample_count] + mixed.fill(0.0) + + # Source state and the shared work buffers are owned for the duration + # of one mix. Sources first write into their assigned bus. The bus + # graph is processed afterwards so DSP order is deterministic: + # source -> bus effects -> bus gain -> parent -> ... -> master. + with self._sources_lock: + bus_buffers = self._prepare_bus_buffers(sample_count) + + for source in self._sources: + if source.state != AudioSourceState.PLAYING: + continue + + bus = self.mixer.resolve_bus(source.bus) + self._mix_source( + source, + bus_buffers[bus.id][:sample_count], + frame_count, + ) - self._mix_source( - source, + self._process_bus_graph( + bus_buffers, mixed, frame_count, + sample_count, ) - self._clamp( - mixed - ) + # Sources can finish naturally during the block above. Release + # them immediately instead of waiting for another update tick. + self._prune_stopped_sources_locked() - return encode_float32( - mixed - ) + self._clamp(mixed) + return encode_float32(mixed) + + def _prepare_bus_buffers( + self, + sample_count: int, + ) -> dict[str, np.ndarray]: + """Return zeroed reusable work buffers keyed by stable bus id.""" + + active_keys: set[str] = set() + for bus in self.mixer.buses: + key = bus.id + active_keys.add(key) + buffer = self._bus_buffers.get(key) + if buffer is None or buffer.size < sample_count: + old_size = 0 if buffer is None else buffer.size + new_size = max(sample_count, old_size * 2, self._mix_buffer.size) + buffer = np.zeros(new_size, dtype=np.float32) + self._bus_buffers[key] = buffer + buffer[:sample_count].fill(0.0) + + for key in tuple(self._bus_buffers): + if key not in active_keys: + del self._bus_buffers[key] + + if self._send_buffer.size < sample_count: + new_size = max(sample_count, self._send_buffer.size * 2, self._mix_buffer.size) + self._send_buffer = np.zeros(new_size, dtype=np.float32) + + return self._bus_buffers + + def _route_send( + self, + block: np.ndarray, + target: np.ndarray, + amount: float, + sample_count: int, + ) -> None: + if amount <= 0.0: + return + if amount == 1.0: + np.add(target, block, out=target) + return + scratch = self._send_buffer[:sample_count] + np.multiply(block, amount, out=scratch) + np.add(target, scratch, out=target) + + def _process_bus_graph( + self, + bus_buffers: dict[str, np.ndarray], + output: np.ndarray, + frame_count: int, + sample_count: int, + ) -> None: + """Process the acyclic routing graph including pre/post-fader sends.""" + + for bus in self.mixer.processing_order(): + block = bus_buffers[bus.id][:sample_count] + frames = block.reshape(frame_count, self.output_channels) + + if bus.muted or not self.mixer.is_bus_audible(bus): + block.fill(0.0) + bus.reset_meter(clear_hold=False) + else: + # Headroom is a Master pre-DSP stage so the complete master + # chain, including a limiter, sees the reserved level. + if bus is self.mixer.master: + headroom = self.mixer.headroom_gain + if headroom != 1.0: + np.multiply(block, headroom, out=block) + + bus.process_effects( + frames, + sample_rate=self.output_frequency, + channels=self.output_channels, + ) + + # Pre-fader sends branch after bus DSP but before fader/pan. + for send in self.mixer.get_sends_from(bus): + if not send.enabled or not send.pre_fader: + continue + target = bus_buffers[send.target_bus_id][:sample_count] + self._route_send(block, target, send.amount, sample_count) + + if bus.volume != 1.0: + np.multiply(block, bus.volume, out=block) + + if bus.pan != 0.0: + left_gain, right_gain = calculate_pan_gains(bus.pan) + np.multiply(frames[:, 0], left_gain, out=frames[:, 0]) + np.multiply(frames[:, 1], right_gain, out=frames[:, 1]) + + # Optional dedicated safety limiter is always the final Master + # DSP stage, after the Master fader/pan and before metering. + if bus is self.mixer.master: + limiter = self.mixer.master_limiter + if limiter is not None and limiter.enabled: + limiter_scratch = self._send_buffer[:sample_count].reshape( + frame_count, self.output_channels + ) + limiter.process_mixed( + frames, + limiter_scratch, + sample_rate=self.output_frequency, + channels=self.output_channels, + ) + + bus.update_meter(frames) + + # Post-fader sends receive exactly what this bus contributes to + # its regular parent route. + for send in self.mixer.get_sends_from(bus): + if not send.enabled or send.pre_fader: + continue + target = bus_buffers[send.target_bus_id][:sample_count] + self._route_send(block, target, send.amount, sample_count) + + if bus is self.mixer.master: + np.copyto(output, block) + else: + parent_bus = bus.parent or self.mixer.master + parent = bus_buffers[parent_bus.id][:sample_count] + np.add(parent, block, out=parent) # ========================================================== # MIX SOURCE @@ -323,7 +518,7 @@ def mix( def _mix_source( self, source: AudioSource, - output: list[float], + output: np.ndarray, frame_count: int, ) -> None: if getattr(source.sound, "streaming", False): @@ -374,9 +569,7 @@ def _mix_source( return volume = ( - source.get_effective_volume( - self.mixer - ) + source.get_source_volume() ) ( @@ -590,7 +783,7 @@ def _mix_source( def _mix_source_fast( self, source: AudioSource, - output: list[float], + output: np.ndarray, frame_count: int, ) -> None: """Mix an unpitched, non-fading PCM source without interpolation.""" @@ -602,12 +795,16 @@ def _mix_source_fast( return total_frames = len(samples) // source_channels + frames = samples[: total_frames * source_channels].reshape( + total_frames, + source_channels, + ) if total_frames <= 0: source.stop() return - volume = source.get_effective_volume(self.mixer) + volume = source.get_source_volume() spatial_volume, pan = source.get_spatial_parameters( self.listener.position, ) @@ -634,24 +831,18 @@ def _mix_source_fast( total_frames - frame, ) + out = output[output_index:output_index + count * 2].reshape(-1, 2) + + chunk = frames[frame:frame + count] if source_channels == 1: - for index in range(count): - sample = samples[frame + index] - output[output_index] += sample * left_scale - output[output_index + 1] += sample * right_scale - output_index += 2 + mono = chunk[:, 0] + out[:, 0] += mono * left_scale + out[:, 1] += mono * right_scale else: - sample_index = frame * source_channels + out[:, 0] += chunk[:, 0] * left_scale + out[:, 1] += chunk[:, 1] * right_scale - for _ in range(count): - output[output_index] += ( - samples[sample_index] * left_scale - ) - output[output_index + 1] += ( - samples[sample_index + 1] * right_scale - ) - output_index += 2 - sample_index += source_channels + output_index += count * 2 frame += count remaining -= count @@ -665,7 +856,7 @@ def _mix_source_fast( def _mix_stream_source( self, source: AudioSource, - output: list[float], + output: np.ndarray, frame_count: int, ) -> None: """Mix a bounded WAV window without creating a full PCM cache.""" @@ -690,7 +881,7 @@ def _mix_stream_source( ) return - volume = source.get_effective_volume(self.mixer) + volume = source.get_source_volume() spatial_volume, pan = source.get_spatial_parameters( self.listener.position, ) @@ -746,7 +937,7 @@ def _mix_stream_source( def _mix_stream_source_fast( self, source: AudioSource, - output: list[float], + output: np.ndarray, frame_count: int, ) -> None: """Mix sequential streamed PCM with one bounded chunk lookup.""" @@ -763,7 +954,7 @@ def _mix_stream_source_fast( source.stop() return - volume = source.get_effective_volume(self.mixer) + volume = source.get_source_volume() spatial_volume, pan = source.get_spatial_parameters( self.listener.position, ) @@ -804,26 +995,20 @@ def _mix_stream_source_fast( source._finish() break - samples = reader._cache - sample_index = (frame - cache_start) * source_channels + cache_frames = reader._cache.reshape(-1, source_channels) + cache_offset = frame - cache_start + chunk = cache_frames[cache_offset:cache_offset + count] + out = output[output_index:output_index + count * 2].reshape(-1, 2) if source_channels == 1: - for _ in range(count): - sample = samples[sample_index] - output[output_index] += sample * left_scale - output[output_index + 1] += sample * right_scale - output_index += 2 - sample_index += 1 + mono = chunk[:, 0] + out[:, 0] += mono * left_scale + out[:, 1] += mono * right_scale else: - for _ in range(count): - output[output_index] += ( - samples[sample_index] * left_scale - ) - output[output_index + 1] += ( - samples[sample_index + 1] * right_scale - ) - output_index += 2 - sample_index += source_channels + out[:, 0] += chunk[:, 0] * left_scale + out[:, 1] += chunk[:, 1] * right_scale + + output_index += count * 2 frame += count remaining -= count @@ -840,23 +1025,9 @@ def _mix_stream_source_fast( @staticmethod def _clamp( - samples: list[float], + samples: np.ndarray, ) -> None: - for ( - index, - sample, - ) in enumerate( - samples - ): - if sample > 1.0: - samples[ - index - ] = 1.0 - - elif sample < -1.0: - samples[ - index - ] = -1.0 + np.clip(samples, -1.0, 1.0, out=samples) # ========================================================== # UPDATE @@ -897,10 +1068,15 @@ def update( # Nothing playing # ====================================================== - active_source_count = sum( - source.state == AudioSourceState.PLAYING - for source in self._sources - ) + with self._sources_lock: + # Finished one-shot SFX/voices must not accumulate forever. Do + # this before the early return below so a player containing only + # stopped sources still releases them immediately. + self._prune_stopped_sources_locked() + active_source_count = sum( + source.state == AudioSourceState.PLAYING + for source in self._sources + ) if active_source_count <= 0: return diff --git a/nexora/audio/preset.py b/nexora/audio/preset.py new file mode 100644 index 0000000..5ac659f --- /dev/null +++ b/nexora/audio/preset.py @@ -0,0 +1,302 @@ +from __future__ import annotations + +import copy +import json +from dataclasses import dataclass +from pathlib import Path +from threading import RLock +from typing import Iterable + +from .bus import AudioBus +from .effects import ( + AudioEffect, + CompressorEffect, + DelayEffect, + DistortionEffect, + HighPassFilterEffect, + LowPassFilterEffect, + NoiseGateEffect, + ParametricEQEffect, + ReverbEffect, + StereoWidthEffect, + audio_effect_from_state, +) + + +def _normalize_name(name: str) -> str: + value = str(name).strip() + if not value: + raise ValueError("Audio preset name must not be empty.") + return value + + +@dataclass(frozen=True, slots=True) +class AudioPreset: + """Serializable named DSP effect chain. + + Presets store effect configuration, never live effect objects. Applying a + preset therefore creates fresh stateful DSP instances for the destination + bus so delay/reverb/filter state cannot leak between buses. + """ + + name: str + effects: tuple[dict, ...] + description: str = "" + + def __post_init__(self) -> None: + object.__setattr__(self, "name", _normalize_name(self.name)) + object.__setattr__(self, "description", str(self.description).strip()) + object.__setattr__( + self, + "effects", + tuple(copy.deepcopy(effect_state) for effect_state in self.effects), + ) + + @classmethod + def from_effects( + cls, + name: str, + effects: Iterable[AudioEffect], + *, + description: str = "", + ) -> "AudioPreset": + return cls( + name=_normalize_name(name), + description=description, + effects=tuple(effect.to_state() for effect in effects), + ) + + @classmethod + def from_bus( + cls, + name: str, + bus: AudioBus, + *, + description: str = "", + ) -> "AudioPreset": + return cls.from_effects(name, bus.effects, description=description) + + def instantiate_effects(self) -> tuple[AudioEffect, ...]: + return tuple(audio_effect_from_state(state) for state in self.effects) + + def to_dict(self) -> dict: + return { + "version": 1, + "name": self.name, + "description": self.description, + "effects": [copy.deepcopy(state) for state in self.effects], + } + + @classmethod + def from_dict(cls, data: dict) -> "AudioPreset": + if not isinstance(data, dict): + raise TypeError("Audio preset data must be a dictionary.") + version = int(data.get("version", 1)) + if version != 1: + raise ValueError(f"Unsupported audio preset version: {version}") + effects = data.get("effects") + if not isinstance(effects, list): + raise ValueError("Audio preset must contain an effects list.") + return cls( + name=data.get("name", ""), + description=data.get("description", ""), + effects=tuple(copy.deepcopy(state) for state in effects), + ) + + +class AudioPresetRegistry: + """Thread-safe registry for built-in and user-defined DSP presets.""" + + def __init__(self, *, include_builtins: bool = True) -> None: + self._lock = RLock() + self._presets: dict[str, AudioPreset] = {} + if include_builtins: + for preset in create_builtin_presets(): + self.register(preset) + + def register( + self, + preset: AudioPreset, + *, + overwrite: bool = False, + ) -> AudioPreset: + if not isinstance(preset, AudioPreset): + raise TypeError("preset must be an AudioPreset.") + key = preset.name.casefold() + with self._lock: + if key in self._presets and not overwrite: + raise ValueError(f"Audio preset already exists: {preset.name}") + self._presets[key] = preset + return preset + + def register_effects( + self, + name: str, + effects: Iterable[AudioEffect], + *, + description: str = "", + overwrite: bool = False, + ) -> AudioPreset: + return self.register( + AudioPreset.from_effects(name, effects, description=description), + overwrite=overwrite, + ) + + def capture_bus( + self, + name: str, + bus: AudioBus, + *, + description: str = "", + overwrite: bool = False, + ) -> AudioPreset: + return self.register( + AudioPreset.from_bus(name, bus, description=description), + overwrite=overwrite, + ) + + def unregister(self, name: str) -> AudioPreset: + key = _normalize_name(name).casefold() + with self._lock: + try: + return self._presets.pop(key) + except KeyError as exc: + raise KeyError(f"Unknown audio preset: {name}") from exc + + def has(self, name: str) -> bool: + key = str(name).strip().casefold() + with self._lock: + return bool(key) and key in self._presets + + def get(self, name: str) -> AudioPreset: + key = _normalize_name(name).casefold() + with self._lock: + try: + return self._presets[key] + except KeyError as exc: + raise KeyError(f"Unknown audio preset: {name}") from exc + + @property + def presets(self) -> tuple[AudioPreset, ...]: + with self._lock: + return tuple(sorted(self._presets.values(), key=lambda p: p.name.casefold())) + + @property + def names(self) -> tuple[str, ...]: + return tuple(preset.name for preset in self.presets) + + def apply( + self, + name: str, + bus: AudioBus, + *, + replace: bool = True, + ) -> tuple[AudioEffect, ...]: + preset = self.get(name) + effects = preset.instantiate_effects() + if replace: + bus.clear_effects() + for effect in effects: + bus.add_effect(effect) + return effects + + def save(self, name: str, path: str | Path) -> Path: + preset = self.get(name) + target = Path(path) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text( + json.dumps(preset.to_dict(), indent=2, ensure_ascii=False), + encoding="utf-8", + ) + return target + + def load( + self, + path: str | Path, + *, + overwrite: bool = False, + name: str | None = None, + ) -> AudioPreset: + source = Path(path) + data = json.loads(source.read_text(encoding="utf-8")) + preset = AudioPreset.from_dict(data) + if name is not None: + preset = AudioPreset( + name=name, + description=preset.description, + effects=preset.effects, + ) + return self.register(preset, overwrite=overwrite) + + +def create_builtin_presets() -> tuple[AudioPreset, ...]: + """Return Nexora's default game-audio DSP presets.""" + + return ( + AudioPreset.from_effects( + "Radio", + ( + NoiseGateEffect(-42.0, 1.0, 20.0, 80.0), + HighPassFilterEffect(260.0, q=0.8), + LowPassFilterEffect(4_800.0, q=0.8), + ParametricEQEffect(1_800.0, 3.0, 1.1), + CompressorEffect(-20.0, 4.0, 4.0, 90.0, 2.0), + DistortionEffect(drive=1.6, output_gain_db=-1.0, wet=0.2), + ), + description="Band-limited compressed radio/voice communication.", + ), + AudioPreset.from_effects( + "Telephone", + ( + HighPassFilterEffect(420.0, q=0.9), + LowPassFilterEffect(3_300.0, q=0.9), + ParametricEQEffect(1_600.0, 4.0, 1.0), + CompressorEffect(-22.0, 5.0, 3.0, 80.0, 2.5), + ), + description="Narrow-band telephone sound.", + ), + AudioPreset.from_effects( + "Underwater", + ( + LowPassFilterEffect(900.0, q=0.75), + ParametricEQEffect(180.0, 3.0, 0.8), + ReverbEffect(0.65, 0.75, 0.72, wet=0.32), + ), + description="Muffled underwater ambience.", + ), + AudioPreset.from_effects( + "Cave", + ( + HighPassFilterEffect(80.0), + DelayEffect(0.085, 0.18, wet=0.14), + ReverbEffect(0.9, 0.28, 0.9, wet=0.48), + ), + description="Large reflective cave space.", + ), + AudioPreset.from_effects( + "Hall", + ( + HighPassFilterEffect(70.0), + ReverbEffect(0.82, 0.38, 0.84, wet=0.36), + ), + description="Large hall reverb.", + ), + AudioPreset.from_effects( + "Distorted Speaker", + ( + HighPassFilterEffect(180.0), + LowPassFilterEffect(6_500.0), + DistortionEffect(drive=3.2, output_gain_db=-3.0, wet=0.7), + CompressorEffect(-16.0, 6.0, 2.0, 70.0, -1.0), + ), + description="Overdriven small speaker / PA character.", + ), + AudioPreset.from_effects( + "Wide Music", + ( + StereoWidthEffect(1.35, wet=1.0), + CompressorEffect(-10.0, 2.0, 15.0, 180.0, 0.0, wet=0.35), + ), + description="Gentle stereo widening and glue compression.", + ), + ) diff --git a/nexora/audio/send.py b/nexora/audio/send.py new file mode 100644 index 0000000..cda9007 --- /dev/null +++ b/nexora/audio/send.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from uuid import uuid4 + + +@dataclass(slots=True) +class AudioSend: + """A parallel routing edge between two mixer buses.""" + + source_bus_id: str + target_bus_id: str + amount: float = 1.0 + pre_fader: bool = False + enabled: bool = True + id: str = field(default_factory=lambda: uuid4().hex) + + def __post_init__(self) -> None: + self.amount = self.amount + + def __setattr__(self, name: str, value) -> None: + if name == "amount": + value = float(value) + if value < 0.0: + raise ValueError("Audio send amount must not be negative.") + object.__setattr__(self, name, value) diff --git a/nexora/audio/sound.py b/nexora/audio/sound.py index 4ccdfa9..c43b0ce 100644 --- a/nexora/audio/sound.py +++ b/nexora/audio/sound.py @@ -41,14 +41,17 @@ def size(self) -> int: def pcm(self) -> PcmSamples: """Return decoded PCM samples, decoding only once. - Long clips use a packed float array so a multi-minute WAV does not - expand into millions of Python float objects. + PCM is cached as one contiguous NumPy float32 array so the mixer can + slice it without per-update conversion or Python sample objects. """ if self._pcm_cache is None: - self._pcm_cache = decode_pcm( - self.buffer - ) + pcm = decode_pcm(self.buffer) + # Decoded assets are shared by all playback instances. Keep them + # immutable so concurrent free-threaded mixers can safely read the + # same cache without accidental in-place edits. + pcm.setflags(write=False) + self._pcm_cache = pcm return self._pcm_cache diff --git a/nexora/audio/source.py b/nexora/audio/source.py index 48e25df..0c86f35 100644 --- a/nexora/audio/source.py +++ b/nexora/audio/source.py @@ -3,7 +3,6 @@ from enum import Enum from .bus import AudioBus -from .channel import AudioChannel from .mixer import AudioMixer from .sound import Sound from .spatial import ( @@ -26,18 +25,15 @@ def __init__( self, sound: Sound, *, - channel: AudioChannel = AudioChannel.SFX, volume: float = 1.0, loop: bool = False, position: tuple[float, float] = (0.0, 0.0), min_distance: float = 1.0, max_distance: float = 1000.0, pitch: float = 1.0, - bus: AudioBus | None = None, + bus: AudioBus | str | None = "Master", ) -> None: self.sound = sound - self.channel = channel - self._volume = 1.0 self._loop = bool(loop) self._state = AudioSourceState.STOPPED @@ -160,7 +156,7 @@ def volume(self, value: float) -> None: # ------------------------------------------------------------------ @property - def bus(self) -> AudioBus | None: + def bus(self) -> AudioBus | str | None: """Return the audio bus.""" return self._bus @@ -168,7 +164,7 @@ def bus(self) -> AudioBus | None: @bus.setter def bus( self, - value: AudioBus | None, + value: AudioBus | str | None, ) -> None: """Set the audio bus.""" @@ -372,6 +368,7 @@ def _start_fade( self._state = AudioSourceState.STOPPED self._position = 0 self._playback_position = 0.0 + self._close_stream_reader() return @@ -443,6 +440,7 @@ def _advance_fade(self, frame_count: int) -> None: self._state = AudioSourceState.STOPPED self._position = 0 self._playback_position = 0.0 + self._close_stream_reader() def _clear_fade(self) -> None: """Clear the current fade state.""" @@ -496,25 +494,42 @@ def stop(self) -> None: # Mixing # ------------------------------------------------------------------ + def get_source_volume(self) -> float: + """Return the source/fade gain before bus processing.""" + + return self._get_fade_volume() + def get_effective_volume( self, mixer: AudioMixer, ) -> float: - """Return the final volume after channel and bus mixing.""" - - volume = ( - self._get_fade_volume() - * mixer.get_effective_volume(self.channel) - ) - - # Master bus affects every audio source. - volume *= mixer.master.effective_volume - - # Assigned bus affects only sources using that bus. - if self._bus is not None: - volume *= self._bus.effective_volume - - return volume + """Return source gain multiplied by the hierarchical bus volume. + + This compatibility helper is useful for inspection. The real-time + player routes sources through bus buffers and therefore applies bus + gain later, after that bus' DSP chain. + """ + + # Preserve the state of an explicitly supplied AudioBus object. + # ``AudioMixer.resolve_bus()`` intentionally canonicalizes bus objects + # by name for the realtime routing graph, but this inspection helper + # must still report the gain represented by the source's actual bus + # reference (including mute). A detached custom bus is implicitly + # routed through Master, matching the realtime bus graph semantics. + if isinstance(self._bus, AudioBus): + bus = self._bus + volume = bus.effective_volume + + if ( + bus is not mixer.master + and bus.parent is None + ): + volume *= mixer.master.effective_volume + + return self.get_source_volume() * volume + + bus = mixer.resolve_bus(self._bus) + return self.get_source_volume() * bus.effective_volume def get_spatial_volume( self, diff --git a/nexora/audio/stream.py b/nexora/audio/stream.py index 9dab22a..414a97e 100644 --- a/nexora/audio/stream.py +++ b/nexora/audio/stream.py @@ -1,11 +1,11 @@ from __future__ import annotations -from array import array from dataclasses import dataclass -import math from pathlib import Path import struct +import numpy as np + STREAM_CHUNK_FRAMES = 8192 @@ -112,14 +112,20 @@ def _read_wav_stream_info(path: Path) -> WavStreamInfo: ) -def _decode_24bit(data: bytes): - for offset in range(0, len(data), 3): - value = int.from_bytes( - data[offset:offset + 3], - byteorder="little", - signed=True, - ) - yield value / 8388608.0 +def _decode_24bit(data: bytes) -> np.ndarray: + raw = np.frombuffer(data, dtype=np.uint8) + usable = (raw.size // 3) * 3 + if usable == 0: + return np.empty(0, dtype=np.float32) + + triplets = raw[:usable].reshape(-1, 3) + values = ( + triplets[:, 0].astype(np.int32) + | (triplets[:, 1].astype(np.int32) << 8) + | (triplets[:, 2].astype(np.int32) << 16) + ) + values = (values ^ 0x00800000) - 0x00800000 + return values.astype(np.float32) * np.float32(1.0 / 8388608.0) def _decode_samples( @@ -127,49 +133,36 @@ def _decode_samples( *, format_tag: int, bytes_per_sample: int, -) -> array: - """Decode one bounded stream chunk into packed float samples.""" +) -> np.ndarray: + """Decode one bounded stream chunk into contiguous float32 samples.""" if not data: - return array("f") + return np.empty(0, dtype=np.float32) if format_tag == 3: - values = array( - "f", - ( - sample - if math.isfinite(sample) - else 0.0 - for (sample,) in struct.iter_unpack(" None: self._file = info.path.open("rb") self._cache_start = -1 self._cache_frames = 0 - self._cache = array("f") + self._cache = np.empty(0, dtype=np.float32) def close(self) -> None: if not self._file.closed: @@ -192,14 +185,14 @@ def seek(self, frame: int) -> None: frame = max(0, min(int(frame), self.info.sample_count)) self._cache_start = -1 self._cache_frames = 0 - self._cache = array("f") + self._cache = np.empty(0, dtype=np.float32) def _fill(self, frame: int) -> None: frame = max(0, min(int(frame), self.info.sample_count)) if frame >= self.info.sample_count: self._cache_start = frame self._cache_frames = 0 - self._cache = array("f") + self._cache = np.empty(0, dtype=np.float32) return frames_to_read = min( diff --git a/nexora/audio/wav.py b/nexora/audio/wav.py index 814007a..5615ded 100644 --- a/nexora/audio/wav.py +++ b/nexora/audio/wav.py @@ -1,7 +1,8 @@ from __future__ import annotations -import math import struct + +import numpy as np import wave from pathlib import Path @@ -113,37 +114,23 @@ def _load_float_wav(path: Path) -> AudioBuffer: f"Invalid float WAV data: {path}" ) - sample_count = data_size // 4 - - pcm_data = bytearray(sample_count * 2) - - # Do not use struct.unpack here. It creates one Python float object - # per sample and keeps all of them alive at once. iter_unpack keeps - # the conversion bounded while the destination remains compact. - data_view = memoryview(raw_file)[data_start:data_end] - - for index, (sample,) in enumerate( - struct.iter_unpack("= 0.0 + scaled = samples * np.float32(32768.0) + scaled[positive] = samples[positive] * np.float32(32767.0) + pcm_data = scaled.astype(" argparse.ArgumentParser: ), ) + parser.add_argument( + "--audioedit", + nargs="?", + const=".", + default=None, + metavar="PROJECT", + help=( + "Start the standalone Audio Mixer Editor. " + "Without PROJECT the current directory is opened." + ), + ) + parser.add_argument( "--cutsceneedit", "--cutscene-editor", @@ -85,7 +97,7 @@ def build_parser() -> argparse.ArgumentParser: "--tilemap", default=None, metavar="ASSET", - help="Open this .tilemap.net asset in the standalone TileMap Editor.", + help="Open this .ntmap asset in the standalone TileMap Editor.", ) parser.add_argument( @@ -141,6 +153,7 @@ def main( ("--editor-tools", args.editor_tools), ("--tilemapedit", args.tilemapedit), ("--itemedit", args.itemedit), + ("--audioedit", args.audioedit), ("--cutsceneedit", args.cutsceneedit), ) if value is not None @@ -180,9 +193,9 @@ def main( ) if args.tilemapedit is not None: - from nexora.editor import run_tilemap_editor + from nexora.editor import run_standalone_tilemap_editor - return run_tilemap_editor( + return run_standalone_tilemap_editor( project_path=args.tilemapedit, tilemap_path=selected_tilemap, ) @@ -195,6 +208,13 @@ def main( item_path=args.item, ) + if args.audioedit is not None: + from nexora.editor import run_audio_mixer_editor + + return run_audio_mixer_editor( + project_path=args.audioedit, + ) + if args.cutsceneedit is not None: from nexora.editor import run_cutscene_editor diff --git a/nexora/core/defaults/audio.toml b/nexora/core/defaults/audio.toml index ba1725b..79e31ee 100644 --- a/nexora/core/defaults/audio.toml +++ b/nexora/core/defaults/audio.toml @@ -1,21 +1,5 @@ # ============================================================ -# Nexora Engine - Default Audio Settings -# ============================================================ - -# ============================================================ -# MIXER CHANNELS -# ============================================================ - -[volume] -master = 1.0 -music = 1.0 -sfx = 1.0 -ambient = 1.0 -voice = 1.0 - - -# ============================================================ -# AUDIO BUSES +# Nexora Engine - Default Audio Bus Graph # ============================================================ [buses.master] @@ -23,17 +7,21 @@ volume = 1.0 muted = false [buses.music] +parent = "master" volume = 1.0 muted = false [buses.sfx] +parent = "master" volume = 1.0 muted = false [buses.ambient] +parent = "master" volume = 1.0 muted = false [buses.voice] +parent = "master" volume = 1.0 -muted = false \ No newline at end of file +muted = false diff --git a/nexora/core/engine.py b/nexora/core/engine.py index a6ea262..d093c83 100644 --- a/nexora/core/engine.py +++ b/nexora/core/engine.py @@ -5,7 +5,6 @@ from nexora.assets import AssetManager from nexora.audio import ( - AudioChannel, AudioSystem, ) @@ -974,84 +973,38 @@ def reset_input_bindings( def _apply_audio_settings( self, ) -> None: - """ - Apply effective audio.toml values to AudioSystem. - """ - - # ====================================================== - # Mixer channel volumes - # ====================================================== - - channel_map = { - "master": ( - AudioChannel.MASTER - ), - "music": ( - AudioChannel.MUSIC - ), - "sfx": ( - AudioChannel.SFX - ), - "ambient": ( - AudioChannel.AMBIENT - ), - "voice": ( - AudioChannel.VOICE - ), - } - - for ( - name, - channel, - ) in channel_map.items(): - volume = float( - self.audio_settings.get( - f"volume.{name}" - ) - ) - - self.audio.set_volume( - channel, - volume, - ) - - # ====================================================== - # Audio buses - # ====================================================== - - bus_map = { - "master": "Master", - "music": "Music", - "sfx": "SFX", - "ambient": "Ambient", - "voice": "Voice", - } - - for ( - setting_name, - bus_name, - ) in bus_map.items(): - bus = ( - self.audio.get_bus( - bus_name - ) - ) + """Apply the dynamic bus graph from audio.toml.""" - bus.volume = float( - self.audio_settings.get( - f"buses." - f"{setting_name}." - f"volume" - ) - ) + buses = self.audio_settings.get("buses", {}) + if not isinstance(buses, dict): + return - bus.muted = bool( - self.audio_settings.get( - f"buses." - f"{setting_name}." - f"muted" - ) - ) + # Create every bus first so parent assignment is independent of TOML + # declaration order. Existing runtime/default buses are reused. + for name, config in buses.items(): + if str(name).casefold() == "master": + continue + if not self.audio.has_bus(str(name)): + self.audio.create_bus(str(name), parent="Master") + + # Apply parent relationships before volume/mute state. + for name, config in buses.items(): + if not isinstance(config, dict): + continue + bus_name = str(name) + if bus_name.casefold() == "master": + continue + parent = config.get("parent", "Master") + self.audio.set_bus_parent(bus_name, str(parent)) + + for name, config in buses.items(): + if not isinstance(config, dict): + continue + bus = self.audio.get_bus(str(name)) + if "volume" in config: + bus.volume = float(config["volume"]) + if "muted" in config: + bus.muted = bool(config["muted"]) def apply_audio_settings( self, diff --git a/nexora/core/game_loop.py b/nexora/core/game_loop.py index f374fcb..0c3331d 100644 --- a/nexora/core/game_loop.py +++ b/nexora/core/game_loop.py @@ -225,7 +225,26 @@ def overlay_scope(): False, ) ): - debug_overlay.toggle() + shift_down = ( + self.input.key_down( + "lshift" + ) + or self.input.key_down( + "rshift" + ) + ) + + if shift_down: + toggle_audio = getattr( + debug_overlay, + "toggle_audio", + None, + ) + + if toggle_audio is not None: + toggle_audio() + else: + debug_overlay.toggle() if ( debug_overlay is not None diff --git a/nexora/debug/overlay.py b/nexora/debug/overlay.py index 17dc237..45560ac 100644 --- a/nexora/debug/overlay.py +++ b/nexora/debug/overlay.py @@ -27,6 +27,7 @@ def __init__( self.engine = engine self.visible = False + self.audio_visible = False self.metrics = ( DebugMetrics() @@ -75,6 +76,25 @@ def hide( ) -> None: self.visible = False + def toggle_audio( + self, + ) -> bool: + self.audio_visible = ( + not self.audio_visible + ) + + return self.audio_visible + + def show_audio( + self, + ) -> None: + self.audio_visible = True + + def hide_audio( + self, + ) -> None: + self.audio_visible = False + # ========================================================== # UPDATE # ========================================================== @@ -83,7 +103,10 @@ def update( self, delta_time: float, ) -> None: - if not self.visible: + if not ( + self.visible + or self.audio_visible + ): return self.metrics.update( @@ -119,28 +142,42 @@ def render( # Debug information overlay # ====================================================== - if not self.visible: + if not ( + self.visible + or self.audio_visible + ): return - left_lines = ( - self._build_left_lines() - ) + if self.visible: + left_lines = ( + self._build_left_lines() + ) - right_lines = ( - self._build_right_lines( - renderer + right_lines = ( + self._build_right_lines( + renderer + ) ) - ) - self._draw_left( - renderer, - left_lines, - ) + self._draw_left( + renderer, + left_lines, + ) - self._draw_right( - renderer, - right_lines, - ) + self._draw_right( + renderer, + right_lines, + ) + + if self.audio_visible: + audio_lines = ( + self._build_audio_mixer_lines() + ) + + self._draw_audio_mixer_bottom_left( + renderer, + audio_lines, + ) # ========================================================== # LEFT SIDE @@ -208,7 +245,7 @@ def _build_left_lines( else "" ) - return [ + lines = [ ( "FPS: " f"{data.fps:.0f} / " @@ -250,6 +287,450 @@ def _build_left_lines( ), ] + return lines + + # ========================================================== + # AUDIO MIXER DEBUG + # ========================================================== + + def _build_audio_mixer_lines( + self, + ) -> list[str]: + """Build a compact, read-only live view of Nexora's audio mixer.""" + + audio = getattr( + self.engine, + "audio", + None, + ) + + if audio is None: + return [] + + try: + buses = tuple( + audio.get_buses() + ) + except Exception: + return [ + "", + "Audio Mixer", + "------------------------", + "Unavailable", + ] + + player = getattr( + audio, + "player", + None, + ) + + device = getattr( + audio, + "device", + None, + ) + + # ------------------------------------------------------ + # Sources / sends / device queue + # ------------------------------------------------------ + + try: + source_count = len( + player.sources + ) if player is not None else 0 + except Exception: + source_count = 0 + + try: + send_count = len( + audio.get_sends() + ) + except Exception: + send_count = 0 + + queue_text = "N/A" + + if ( + device is not None + and getattr( + device, + "initialized", + False, + ) + ): + try: + queued_frames = int( + device.queued_frames() + ) + + frequency = int( + getattr( + device, + "frequency", + 0, + ) + ) + + if frequency > 0: + queue_ms = ( + queued_frames + / frequency + * 1000.0 + ) + + queue_text = ( + f"{queue_ms:.1f} ms" + ) + + else: + queue_text = ( + f"{queued_frames} frames" + ) + + except Exception: + queue_text = "N/A" + + lines = [ + "", + "Audio Mixer", + "------------------------", + ( + f"Sources: {source_count} " + f"Buses: {len(buses)} " + f"Sends: {send_count}" + ), + ( + "Queue: " + f"{queue_text}" + ), + ] + + if not buses: + lines.append( + "No buses" + ) + return lines + + # ------------------------------------------------------ + # Keep the debug overlay compact. Master is always first, + # followed by the hierarchy in mixer order. + # ------------------------------------------------------ + + max_buses = 8 + visible_buses = buses[:max_buses] + + for bus in visible_buses: + try: + peak_left, peak_right = ( + bus.peak_dbfs + ) + except Exception: + peak_left = peak_right = float( + "-inf" + ) + + depth = self._audio_bus_depth( + bus + ) + + indent = " " * min( + depth, + 4, + ) + + flags: list[str] = [] + + if getattr( + bus, + "muted", + False, + ): + flags.append("M") + + if getattr( + bus, + "solo", + False, + ): + flags.append("S") + + if getattr( + bus, + "clipped", + False, + ): + flags.append("CLIP") + + state = ( + "[" + " ".join(flags) + "]" + if flags + else "" + ) + + effect_count = len( + getattr( + bus, + "effects", + (), + ) + ) + + outgoing_sends = 0 + try: + outgoing_sends = sum( + 1 + for send in audio.get_sends() + if getattr( + send, + "source_bus_id", + None, + ) == bus.id + ) + except Exception: + outgoing_sends = 0 + + lines.append( + ( + f"{indent}{bus.name}: " + f"V {bus.volume:.2f} " + f"P {bus.pan:+.2f} " + f"FX {effect_count} " + f"Send {outgoing_sends}" + f" {state}" + ).rstrip() + ) + + hidden = ( + len(buses) + - len(visible_buses) + ) + + if hidden > 0: + lines.append( + f"... +{hidden} buses" + ) + + return lines + + @staticmethod + def _audio_bus_depth( + bus, + ) -> int: + depth = 0 + current = getattr( + bus, + "parent", + None, + ) + seen: set[int] = set() + + while current is not None: + identity = id( + current + ) + + if identity in seen: + break + + seen.add( + identity + ) + depth += 1 + current = getattr( + current, + "parent", + None, + ) + + return depth + + @staticmethod + def _format_dbfs( + value: float, + ) -> str: + if value == float( + "-inf" + ): + return "-inf" + + if value < -99.9: + return "<-99" + + return f"{value:.1f}" + + + # ========================================================== + # AUDIO MIXER BOTTOM-LEFT + # ========================================================== + + def _draw_audio_mixer_bottom_left( + self, + renderer, + lines: list[str], + ) -> None: + """Draw the complete audio debug block anchored to the bottom-left.""" + + if not lines: + return + + half_width = renderer.width * 0.5 + half_height = renderer.height * 0.5 + line_height = self._line_height(renderer) + baseline = renderer.text_baseline(scale=self.text_scale) + + # Keep the complete block inside the viewport regardless of the + # amount of regular debug text shown at the top-left. + block_height = max(0.0, (len(lines) - 1) * line_height) + baseline + x = -half_width + self.margin + y = half_height - self.margin - block_height + baseline + + bus_row_indices: list[int] = [] + try: + mixer_index = lines.index("Audio Mixer") + first_bus_index = mixer_index + 4 + audio = getattr(self.engine, "audio", None) + buses = tuple(audio.get_buses())[:8] if audio is not None else () + bus_row_indices = [first_bus_index + i for i in range(len(buses))] + except Exception: + buses = () + + for index, line in enumerate(lines): + if line: + renderer.text( + line, + x, + y + index * line_height, + scale=self.text_scale, + ) + + if not buses: + return + + # Meters sit on the right edge of the left half, but stay vertically + # aligned with the bus text rows in this bottom-left block. + meter_width = max( + 120.0, + min(260.0, half_width * 0.30), + ) + meter_x = -self.margin - meter_width + meter_height = max( + 3.0, + min(5.0, line_height * 0.22), + ) + channel_gap = 2.0 + + for bus, row_index in zip(buses, bus_row_indices): + row_y = y + row_index * line_height + top = row_y - baseline + 2.0 + + try: + peak_left, peak_right = bus.peak_dbfs + except Exception: + peak_left = peak_right = float("-inf") + + try: + hold_left, hold_right = bus.peak_hold_dbfs + except Exception: + hold_left = peak_left + hold_right = peak_right + + clipped = bool(getattr(bus, "clipped", False)) + + self._draw_stereo_meter_channel( + renderer, meter_x, top, meter_width, meter_height, + peak_left, hold_left, clipped, "L", + ) + self._draw_stereo_meter_channel( + renderer, meter_x, top + meter_height + channel_gap, + meter_width, meter_height, peak_right, hold_right, clipped, "R", + ) + + def _draw_stereo_meter_channel( + self, + renderer, + x: float, + y: float, + width: float, + height: float, + peak_dbfs: float, + hold_dbfs: float, + clipped: bool, + channel: str, + ) -> None: + label_width, _ = renderer.text_measure(channel, scale=0.72) + renderer.text( + channel, + x - label_width - 5.0, + y + height + 1.0, + scale=0.72, + ) + + renderer.rect( + x, y, width, height, + color=(0.08, 0.09, 0.10, 0.92), + origin=(0.0, 0.0), + radius=1.5, + ) + + fill_width = width * self._dbfs_to_meter(peak_dbfs) + if fill_width > 0.0: + green_end = width * self._dbfs_to_meter(-12.0) + yellow_end = width * self._dbfs_to_meter(-3.0) + + green_width = min(fill_width, green_end) + if green_width > 0.0: + renderer.rect( + x, y, green_width, height, + color=(0.18, 0.82, 0.28, 1.0), + origin=(0.0, 0.0), radius=1.5, + ) + + yellow_width = max(0.0, min(fill_width, yellow_end) - green_end) + if yellow_width > 0.0: + renderer.rect( + x + green_end, y, yellow_width, height, + color=(0.92, 0.78, 0.16, 1.0), + origin=(0.0, 0.0), + ) + + red_width = max(0.0, fill_width - yellow_end) + if red_width > 0.0: + renderer.rect( + x + yellow_end, y, red_width, height, + color=(0.95, 0.24, 0.18, 1.0), + origin=(0.0, 0.0), radius=1.5, + ) + + hold = self._dbfs_to_meter(hold_dbfs) + if hold > 0.0: + hold_x = x + min(width - 1.0, width * hold) + renderer.rect( + hold_x, y, 1.5, height, + color=(1.0, 1.0, 1.0, 0.95), + origin=(0.0, 0.0), + ) + + if clipped: + renderer.rect( + x + width + 3.0, y, 4.0, height, + color=(1.0, 0.12, 0.08, 1.0), + origin=(0.0, 0.0), radius=1.0, + ) + + @staticmethod + def _dbfs_to_meter( + value: float, + floor_db: float = -60.0, + ) -> float: + if value == float("-inf"): + return 0.0 + if value <= floor_db: + return 0.0 + if value >= 0.0: + return 1.0 + return (value - floor_db) / (-floor_db) + # ========================================================== # RIGHT SIDE # ========================================================== diff --git a/nexora/editor/__init__.py b/nexora/editor/__init__.py index b827c73..8604a73 100644 --- a/nexora/editor/__init__.py +++ b/nexora/editor/__init__.py @@ -1,7 +1,6 @@ from nexora.editor.app import EditorApp, resolve_project_path, run_editor -from nexora.editor.model import EditorDocument, ProjectModel, SelectionService +from nexora.editor.model import EditorDocument, EditorProjectContext, ProjectModel, SelectionService from nexora.editor.tilemap import TileMapEditorModel -from nexora.editor.tilemap_app import TileMapEditorApp, TileMapEditorScene, run_tilemap_editor from nexora.editor.standalone_tilemap_editor import ( StandaloneTileMapEditorApp, StandaloneTileMapEditorScene, @@ -17,27 +16,26 @@ EditorToolsScene, run_editor_tools, ) +from nexora.editor.audio_mixer_editor import ( + AudioMixerEditorApp, + AudioMixerEditorScene, + run_audio_mixer_editor, +) from nexora.editor.cutscene_editor import ( CutsceneEditorApp, CutsceneEditorScene, run_cutscene_editor, ) -# ``--tilemapedit`` now opens the independent low-level editor. Keep the -# legacy names above available for existing integrations and tests. -run_tilemap_editor = run_standalone_tilemap_editor - __all__ = [ "EditorApp", "EditorDocument", + "EditorProjectContext", "ProjectModel", "SelectionService", "TileMapEditorModel", - "TileMapEditorApp", - "TileMapEditorScene", "resolve_project_path", "run_editor", - "run_tilemap_editor", "StandaloneTileMapEditorApp", "StandaloneTileMapEditorScene", "run_standalone_tilemap_editor", @@ -47,6 +45,9 @@ "EditorToolsApp", "EditorToolsScene", "run_editor_tools", + "AudioMixerEditorApp", + "AudioMixerEditorScene", + "run_audio_mixer_editor", "CutsceneEditorApp", "CutsceneEditorScene", "run_cutscene_editor", diff --git a/nexora/editor/app.py b/nexora/editor/app.py index 16cd944..d4b3995 100644 --- a/nexora/editor/app.py +++ b/nexora/editor/app.py @@ -9,6 +9,7 @@ ) from nexora.editor.model import ( EditorDocument, + EditorProjectContext, ProjectModel, SelectionService, ) @@ -63,11 +64,10 @@ def __init__( *, project_path: str | Path | None = None, ) -> None: - self.editor_project_path = ( - resolve_project_path( - project_path - ) + self.editor_project_context = EditorProjectContext.from_path( + resolve_project_path(project_path) ) + self.editor_project_path = self.editor_project_context.root super().__init__( project_name="NexoraEditor", @@ -88,7 +88,7 @@ def __init__( def initialize( self, ) -> None: - icon_path = self.editor_project_path / "assets" / "icon.png" + icon_path = self.editor_project_context.icon_path if self.window is not None and icon_path.is_file(): try: self.window.set_icon(icon_path) @@ -108,6 +108,7 @@ def initialize( project=self.editor_project, document=self.editor_document, selection=self.editor_selection, + project_context=self.editor_project_context, ) self.scene = self.editor_scene diff --git a/nexora/editor/asset_browser.py b/nexora/editor/asset_browser.py index e26a140..e843321 100644 --- a/nexora/editor/asset_browser.py +++ b/nexora/editor/asset_browser.py @@ -7,6 +7,7 @@ import sys from nexora.nodes.ui.containers.panel import Panel +from nexora.tilemap import TILEMAP_ASSET_SUFFIX IMAGE_EXTENSIONS = { @@ -34,7 +35,7 @@ } TILEMAP_EXTENSIONS = { - ".tilemap.net", + TILEMAP_ASSET_SUFFIX, } ITEM_EXTENSIONS = { @@ -131,7 +132,7 @@ def classify_asset(path: Path) -> str: if suffix in PREFAB_EXTENSIONS: return "prefab" - if str(path).lower().endswith(".tilemap.net"): + if suffix in TILEMAP_EXTENSIONS: return "tilemap" if suffix in ITEM_EXTENSIONS: diff --git a/nexora/editor/audio_mixer_editor.py b/nexora/editor/audio_mixer_editor.py new file mode 100644 index 0000000..7f002f0 --- /dev/null +++ b/nexora/editor/audio_mixer_editor.py @@ -0,0 +1,807 @@ +from __future__ import annotations + +"""Standalone visual editor for Nexora's runtime audio mixer. + +Launch with:: + + python -m nexora --audioedit + +The editor intentionally drives the real :class:`AudioSystem` attached to the +editor Game. Changes are therefore immediately reflected in the mixer graph, +DSP chains, sends and meters used by the engine. +""" + +import json +import math +from pathlib import Path +from typing import Callable + +from nexora import Game +from nexora.audio import ( + CompressorEffect, + DelayEffect, + DistortionEffect, + GainEffect, + HighPassFilterEffect, + LimiterEffect, + LowPassFilterEffect, + NoiseGateEffect, + ParametricEQEffect, + ReverbEffect, + StereoWidthEffect, +) +from nexora.editor.app import resolve_project_path +from nexora.editor.model import EditorProjectContext +from nexora.editor.ui import ( + Button, + CheckBox, + Control, + Dropdown, + ListBox, + Rect, + TextField, + UITheme, + draw_outline, + draw_rect, + draw_text, +) +from nexora.scene import Scene + + +_EFFECT_FACTORIES: dict[str, Callable[[], object]] = { + "Gain": GainEffect, + "Limiter": LimiterEffect, + "Low-pass": LowPassFilterEffect, + "High-pass": HighPassFilterEffect, + "Parametric EQ": ParametricEQEffect, + "Compressor": CompressorEffect, + "Delay": DelayEffect, + "Reverb": ReverbEffect, + "Distortion": DistortionEffect, + "Noise Gate": NoiseGateEffect, + "Stereo Width": StereoWidthEffect, +} + + +def _dbfs_label(value: float) -> str: + if not math.isfinite(value): + return "-inf" + return f"{value:.1f} dB" + + +class AudioSlider(Control): + """Small local slider so the mixer editor does not expand shared UI API.""" + + def __init__( + self, + rect: Rect, + minimum: float, + maximum: float, + value: float, + on_change: Callable[[float], None] | None = None, + ) -> None: + super().__init__(rect) + self.minimum = float(minimum) + self.maximum = float(maximum) + self._value = float(value) + self.on_change = on_change + self.dragging = False + self.value = value + + @property + def value(self) -> float: + return self._value + + @value.setter + def value(self, value: float) -> None: + self._value = max(self.minimum, min(self.maximum, float(value))) + + def update(self, input_manager, mouse_x: float, mouse_y: float) -> bool: + super().update(input_manager, mouse_x, mouse_y) + if self.enabled and self.hovered and input_manager.mouse_pressed("left"): + self.dragging = True + if self.dragging and not input_manager.mouse_down("left"): + self.dragging = False + if not self.dragging: + return False + fraction = (float(mouse_x) - self.rect.x) / max(1.0, self.rect.width) + self.value = self.minimum + max(0.0, min(1.0, fraction)) * (self.maximum - self.minimum) + if self.on_change is not None: + self.on_change(self.value) + return True + + def render(self, renderer, viewport, theme: UITheme) -> None: + draw_rect(renderer, self.rect, theme.input, viewport, radius=4.0) + draw_outline(renderer, self.rect, theme.accent if self.dragging else theme.border, viewport) + fraction = (self.value - self.minimum) / max(1e-9, self.maximum - self.minimum) + fill = Rect(self.rect.x + 2.0, self.rect.y + 2.0, max(0.0, (self.rect.width - 4.0) * fraction), self.rect.height - 4.0) + if fill.width > 0.0: + draw_rect(renderer, fill, theme.accent, viewport, radius=3.0) + + +class AudioMixerEditorScene(Scene): + TOOLBAR_HEIGHT = 56.0 + STATUS_HEIGHT = 28.0 + LEFT_WIDTH = 260.0 + RIGHT_WIDTH = 390.0 + SNAPSHOT_NAME = "audio_mixer.json" + + def __init__(self, game, project_path: Path, project_context: EditorProjectContext | None = None) -> None: + super().__init__("StandaloneAudioMixerEditor") + self.game = game + self.project_context = project_context or EditorProjectContext.from_path(project_path) + self.project_path = self.project_context.root + self.theme = UITheme() + self.status = "Ready" + self._layout_size = (-1.0, -1.0) + self._syncing = False + self._selected_bus_id: str | None = None + self._selected_effect = -1 + self._selected_send = -1 + + self.bus_list = ListBox(Rect(0, 0, 1, 1), self._bus_selected) + self.effect_list = ListBox(Rect(0, 0, 1, 1), self._effect_selected) + self.send_list = ListBox(Rect(0, 0, 1, 1), self._send_selected) + + self.add_bus_name = TextField(Rect(0, 0, 1, 1), "", "New bus name") + self.add_bus_button = Button(Rect(0, 0, 1, 1), "Add Bus", self._add_bus) + self.remove_bus_button = Button(Rect(0, 0, 1, 1), "Remove", self._remove_bus) + self.rename_field = TextField(Rect(0, 0, 1, 1), "", "Bus name") + self.rename_field.on_submit = lambda _value: self._rename_bus() + self.rename_button = Button(Rect(0, 0, 1, 1), "Rename", self._rename_bus) + + self.parent_dropdown = Dropdown(Rect(0, 0, 1, 1), ("Master",), on_change=self._parent_changed) + self.volume_slider = AudioSlider(Rect(0, 0, 1, 1), 0.0, 2.0, 1.0, self._volume_changed) + self.pan_slider = AudioSlider(Rect(0, 0, 1, 1), -1.0, 1.0, 0.0, self._pan_changed) + self.mute_check = CheckBox(Rect(0, 0, 1, 1), "Mute", False, self._mute_changed) + self.solo_check = CheckBox(Rect(0, 0, 1, 1), "Solo", False, self._solo_changed) + self.bypass_chain_check = CheckBox(Rect(0, 0, 1, 1), "Bypass DSP chain", False, self._chain_bypass_changed) + self.clear_peak_button = Button(Rect(0, 0, 1, 1), "Clear Peak", self._clear_peak) + + effect_names = tuple(_EFFECT_FACTORIES) + self.add_effect_dropdown = Dropdown(Rect(0, 0, 1, 1), effect_names) + self.add_effect_button = Button(Rect(0, 0, 1, 1), "Add Effect", self._add_effect) + self.remove_effect_button = Button(Rect(0, 0, 1, 1), "Remove Effect", self._remove_effect) + self.effect_bypass_check = CheckBox(Rect(0, 0, 1, 1), "Bypass selected", False, self._effect_bypass_changed) + self.effect_wet_slider = AudioSlider(Rect(0, 0, 1, 1), 0.0, 1.0, 1.0, self._effect_wet_changed) + + preset_names = tuple(preset.name for preset in self.game.audio.get_audio_presets()) or ("Radio",) + self.preset_dropdown = Dropdown(Rect(0, 0, 1, 1), preset_names) + self.apply_preset_button = Button(Rect(0, 0, 1, 1), "Apply Preset", self._apply_preset) + self.append_preset_check = CheckBox(Rect(0, 0, 1, 1), "Append", False) + + self.send_target_dropdown = Dropdown(Rect(0, 0, 1, 1), ("Master",)) + self.send_amount_slider = AudioSlider(Rect(0, 0, 1, 1), 0.0, 2.0, 0.25, self._send_amount_changed) + self.send_pre_check = CheckBox(Rect(0, 0, 1, 1), "Pre-fader", False, self._send_pre_changed) + self.add_send_button = Button(Rect(0, 0, 1, 1), "Add Send", self._add_send) + self.remove_send_button = Button(Rect(0, 0, 1, 1), "Remove Send", self._remove_send) + self.send_enabled_check = CheckBox(Rect(0, 0, 1, 1), "Enabled", True, self._send_enabled_changed) + + self.effect_parameter_fields = [TextField(Rect(0, 0, 1, 1)) for _ in range(6)] + self._effect_parameter_names: list[str] = [] + for index, field in enumerate(self.effect_parameter_fields): + field.on_submit = lambda value, slot=index: self._effect_parameter_submit(slot, value) + + self.save_button = Button(Rect(0, 0, 1, 1), "Save Mixer", self._save_snapshot) + self.load_button = Button(Rect(0, 0, 1, 1), "Load Mixer", self._load_snapshot) + self.reset_button = Button(Rect(0, 0, 1, 1), "Reset", self._reset_mixer) + + self._refresh_bus_list(select_name="Master") + + @property + def snapshot_path(self) -> Path: + return self.project_path / self.SNAPSHOT_NAME + + def _selected_bus(self): + if self._selected_bus_id is None: + return None + try: + return self.game.audio.get_bus_by_id(self._selected_bus_id) + except KeyError: + return None + + def _refresh_bus_list(self, *, select_name: str | None = None) -> None: + buses = self.game.audio.get_buses() + items = [] + selected_index = -1 + for index, bus in enumerate(buses): + parent = bus.parent.name if bus.parent is not None else "—" + prefix = "◆" if bus.name.casefold() == "master" else "•" + items.append(f"{prefix} {bus.name} → {parent}") + if (select_name and bus.name.casefold() == select_name.casefold()) or bus.id == self._selected_bus_id: + selected_index = index + self.bus_list.set_items(items) + if selected_index < 0 and buses: + selected_index = 0 + if selected_index >= 0: + self.bus_list.selected = selected_index + self._selected_bus_id = buses[selected_index].id + self._sync_controls() + + def _sync_controls(self) -> None: + bus = self._selected_bus() + if bus is None: + return + self._syncing = True + try: + self.rename_field.set_text(bus.name) + self.volume_slider.value = bus.volume + self.pan_slider.value = bus.pan + self.mute_check.checked = bus.muted + self.solo_check.checked = bus.solo + self.bypass_chain_check.checked = bus.effects_bypassed + parents = [candidate.name for candidate in self.game.audio.get_buses() if candidate is not bus] + if bus.name.casefold() == "master": + parents = ["—"] + elif "Master" not in parents: + parents.insert(0, "Master") + self.parent_dropdown.options = tuple(parents or ["—"]) + parent_name = bus.parent.name if bus.parent is not None else "Master" + if bus.name.casefold() == "master": + parent_name = "—" + try: + self.parent_dropdown.selected = self.parent_dropdown.options.index(parent_name) + except ValueError: + self.parent_dropdown.selected = 0 + + self.effect_list.set_items([ + f"{index + 1}. {type(effect).__name__} wet {effect.wet:.0%}{' [BYPASS]' if effect.bypassed else ''}" + for index, effect in enumerate(bus.effects) + ]) + if self._selected_effect >= len(bus.effects): + self._selected_effect = len(bus.effects) - 1 + self.effect_list.selected = self._selected_effect + effect = bus.effects[self._selected_effect] if 0 <= self._selected_effect < len(bus.effects) else None + self.effect_bypass_check.checked = bool(effect.bypassed) if effect is not None else False + self.effect_wet_slider.value = effect.wet if effect is not None else 1.0 + self.effect_bypass_check.enabled = effect is not None + self.effect_wet_slider.enabled = effect is not None + self.remove_effect_button.enabled = effect is not None + self._effect_parameter_names = [] + parameter_values = {} if effect is None else dict(effect.to_state().get("parameters") or {}) + for slot, field in enumerate(self.effect_parameter_fields): + if slot < len(parameter_values): + parameter, value = list(parameter_values.items())[slot] + self._effect_parameter_names.append(parameter) + field.set_text(str(value)) + field.enabled = True + field.visible = True + else: + field.set_text("") + field.enabled = False + field.visible = False + + sends = self.game.audio.mixer.get_sends_from(bus) + send_items = [] + for send in sends: + target = self.game.audio.get_bus_by_id(send.target_bus_id) + mode = "pre" if send.pre_fader else "post" + send_items.append(f"{target.name} {send.amount:.2f} {mode}{'' if send.enabled else ' [off]'}") + self.send_list.set_items(send_items) + if self._selected_send >= len(sends): + self._selected_send = len(sends) - 1 + self.send_list.selected = self._selected_send + selected_send = sends[self._selected_send] if 0 <= self._selected_send < len(sends) else None + self.send_enabled_check.enabled = selected_send is not None + self.remove_send_button.enabled = selected_send is not None + if selected_send is not None: + self.send_enabled_check.checked = selected_send.enabled + self.send_amount_slider.value = selected_send.amount + self.send_pre_check.checked = selected_send.pre_fader + + targets = [candidate.name for candidate in self.game.audio.get_buses() if candidate is not bus] + self.send_target_dropdown.options = tuple(targets or ["Master"]) + self.send_target_dropdown.selected = min(self.send_target_dropdown.selected, len(self.send_target_dropdown.options) - 1) + self.remove_bus_button.enabled = bus.name.casefold() != "master" + self.rename_button.enabled = bus.name.casefold() != "master" + self.rename_field.enabled = bus.name.casefold() != "master" + self.parent_dropdown.enabled = bus.name.casefold() != "master" + finally: + self._syncing = False + + def _bus_selected(self, index: int) -> None: + buses = self.game.audio.get_buses() + if 0 <= index < len(buses): + self._selected_bus_id = buses[index].id + self._selected_effect = -1 + self._selected_send = -1 + self._sync_controls() + + def _effect_selected(self, index: int) -> None: + self._selected_effect = index + self._sync_controls() + + def _send_selected(self, index: int) -> None: + self._selected_send = index + self._sync_controls() + + def _add_bus(self) -> None: + name = self.add_bus_name.text.strip() + if not name: + self.status = "Enter a bus name." + return + try: + parent = self._selected_bus() or self.game.audio.mixer.master + self.game.audio.create_bus(name, parent=parent) + self.add_bus_name.set_text("") + self.status = f"Created bus {name}." + self._refresh_bus_list(select_name=name) + except Exception as exc: + self.status = f"Bus error: {exc}" + + def _remove_bus(self) -> None: + bus = self._selected_bus() + if bus is None or bus.name.casefold() == "master": + return + name = bus.name + try: + parent = bus.parent.name if bus.parent is not None else "Master" + self.game.audio.remove_bus(name, reassign_to=parent, reparent_children=True) + self._selected_bus_id = None + self.status = f"Removed bus {name}." + self._refresh_bus_list(select_name=parent) + except Exception as exc: + self.status = f"Remove failed: {exc}" + + def _rename_bus(self) -> None: + bus = self._selected_bus() + if bus is None or bus.name.casefold() == "master": + return + new_name = self.rename_field.text.strip() + if not new_name: + return + try: + self.game.audio.rename_bus(bus.id, new_name) + self.status = f"Renamed bus to {new_name}." + self._refresh_bus_list(select_name=new_name) + except Exception as exc: + self.status = f"Rename failed: {exc}" + + def _parent_changed(self, _index: int, value: str) -> None: + if self._syncing: + return + bus = self._selected_bus() + if bus is None or bus.name.casefold() == "master": + return + try: + self.game.audio.set_bus_parent(bus.name, value) + self.status = f"{bus.name} → {value}" + self._refresh_bus_list(select_name=bus.name) + except Exception as exc: + self.status = f"Routing rejected: {exc}" + self._sync_controls() + + def _volume_changed(self, value: float) -> None: + bus = self._selected_bus() + if bus is not None and not self._syncing: + bus.volume = value + + def _pan_changed(self, value: float) -> None: + bus = self._selected_bus() + if bus is not None and not self._syncing: + bus.pan = value + + def _mute_changed(self, value: bool) -> None: + bus = self._selected_bus() + if bus is not None and not self._syncing: + bus.muted = value + + def _solo_changed(self, value: bool) -> None: + bus = self._selected_bus() + if bus is not None and not self._syncing: + bus.solo = value + + def _chain_bypass_changed(self, value: bool) -> None: + bus = self._selected_bus() + if bus is not None and not self._syncing: + bus.effects_bypassed = value + + def _clear_peak(self) -> None: + bus = self._selected_bus() + if bus is not None: + bus.clear_peak_hold() + + def _add_effect(self) -> None: + bus = self._selected_bus() + factory = _EFFECT_FACTORIES.get(self.add_effect_dropdown.value) + if bus is None or factory is None: + return + try: + bus.add_effect(factory()) + self._selected_effect = len(bus.effects) - 1 + self.status = f"Added {self.add_effect_dropdown.value}." + self._sync_controls() + except Exception as exc: + self.status = f"Effect error: {exc}" + + def _remove_effect(self) -> None: + bus = self._selected_bus() + if bus is None or not (0 <= self._selected_effect < len(bus.effects)): + return + effect = bus.effects[self._selected_effect] + bus.remove_effect(effect) + self._selected_effect = min(self._selected_effect, len(bus.effects) - 1) + self.status = f"Removed {type(effect).__name__}." + self._sync_controls() + + def _effect_bypass_changed(self, value: bool) -> None: + bus = self._selected_bus() + if bus is not None and 0 <= self._selected_effect < len(bus.effects) and not self._syncing: + bus.effects[self._selected_effect].bypassed = value + self._sync_controls() + + def _effect_wet_changed(self, value: float) -> None: + bus = self._selected_bus() + if bus is not None and 0 <= self._selected_effect < len(bus.effects) and not self._syncing: + bus.effects[self._selected_effect].wet = value + + def _apply_preset(self) -> None: + bus = self._selected_bus() + if bus is None: + return + try: + self.game.audio.apply_audio_preset( + self.preset_dropdown.value, + bus, + replace=not self.append_preset_check.checked, + ) + self.status = f"Applied {self.preset_dropdown.value} to {bus.name}." + self._selected_effect = -1 + self._sync_controls() + except Exception as exc: + self.status = f"Preset error: {exc}" + + def _add_send(self) -> None: + bus = self._selected_bus() + if bus is None or not self.send_target_dropdown.value: + return + try: + self.game.audio.add_send( + bus, + self.send_target_dropdown.value, + amount=self.send_amount_slider.value, + pre_fader=self.send_pre_check.checked, + ) + self.status = f"Added send {bus.name} → {self.send_target_dropdown.value}." + self._selected_send = -1 + self._sync_controls() + except Exception as exc: + self.status = f"Send rejected: {exc}" + + def _remove_send(self) -> None: + bus = self._selected_bus() + if bus is None: + return + sends = self.game.audio.mixer.get_sends_from(bus) + if not (0 <= self._selected_send < len(sends)): + return + self.game.audio.remove_send(sends[self._selected_send]) + self._selected_send = -1 + self.status = "Removed send." + self._sync_controls() + + def _send_enabled_changed(self, enabled: bool) -> None: + bus = self._selected_bus() + if bus is None or self._syncing: + return + sends = self.game.audio.mixer.get_sends_from(bus) + if 0 <= self._selected_send < len(sends): + try: + self.game.audio.set_send_enabled(sends[self._selected_send].id, enabled) + except Exception as exc: + self.status = f"Send rejected: {exc}" + self._sync_controls() + + def _send_amount_changed(self, amount: float) -> None: + bus = self._selected_bus() + if bus is None or self._syncing: + return + sends = self.game.audio.mixer.get_sends_from(bus) + if 0 <= self._selected_send < len(sends): + self.game.audio.set_send_amount(sends[self._selected_send].id, amount) + + def _send_pre_changed(self, pre_fader: bool) -> None: + bus = self._selected_bus() + if bus is None or self._syncing: + return + sends = self.game.audio.mixer.get_sends_from(bus) + if 0 <= self._selected_send < len(sends): + self.game.audio.set_send_pre_fader(sends[self._selected_send].id, pre_fader) + self._sync_controls() + + def _effect_parameter_submit(self, slot: int, value: str) -> None: + bus = self._selected_bus() + if bus is None or not (0 <= self._selected_effect < len(bus.effects)): + return + if not (0 <= slot < len(self._effect_parameter_names)): + return + parameter = self._effect_parameter_names[slot] + effect = bus.effects[self._selected_effect] + current = effect.get_parameter(parameter) + try: + if isinstance(current, bool): + parsed = value.strip().casefold() in {"1", "true", "yes", "on"} + elif isinstance(current, int) and not isinstance(current, bool): + parsed = int(value) + elif isinstance(current, float): + parsed = float(value) + else: + parsed = value + effect.set_parameter(parameter, parsed) + self.status = f"{parameter} = {parsed}" + self._sync_controls() + except Exception as exc: + self.status = f"Parameter error: {exc}" + self._sync_controls() + + def _save_snapshot(self) -> None: + try: + self.snapshot_path.write_text( + json.dumps(self.game.audio.create_mixer_snapshot(), indent=2, ensure_ascii=False), + encoding="utf-8", + ) + self.status = f"Saved {self.snapshot_path.name}." + except Exception as exc: + self.status = f"Save failed: {exc}" + + def _load_snapshot(self) -> None: + try: + data = json.loads(self.snapshot_path.read_text(encoding="utf-8")) + self.game.audio.restore_mixer_snapshot(data) + self._selected_bus_id = None + self._selected_effect = -1 + self._selected_send = -1 + self._refresh_bus_list(select_name="Master") + self.status = f"Loaded {self.snapshot_path.name}." + except FileNotFoundError: + self.status = f"{self.snapshot_path.name} does not exist yet." + except Exception as exc: + self.status = f"Load failed: {exc}" + + def _reset_mixer(self) -> None: + self.game.audio.reset() + self.status = "Mixer reset." + self._sync_controls() + + def _layout(self) -> tuple[float, float]: + width = float(self.game.window.width) + height = float(self.game.window.height) + if (width, height) == self._layout_size: + return width, height + self._layout_size = (width, height) + + left = 10.0 + top = self.TOOLBAR_HEIGHT + 10.0 + bottom = height - self.STATUS_HEIGHT - 10.0 + left_panel_w = self.LEFT_WIDTH + right_x = width - self.RIGHT_WIDTH - 10.0 + center_x = left + left_panel_w + 10.0 + center_w = max(360.0, right_x - center_x - 10.0) + + self.bus_list.rect = Rect(left, top + 34.0, left_panel_w, max(180.0, bottom - top - 126.0)) + self.add_bus_name.rect = Rect(left, bottom - 78.0, left_panel_w - 92.0, 32.0) + self.add_bus_button.rect = Rect(left + left_panel_w - 84.0, bottom - 78.0, 84.0, 32.0) + self.remove_bus_button.rect = Rect(left, bottom - 38.0, left_panel_w, 32.0) + + cx = center_x + 14.0 + field_w = min(360.0, center_w - 28.0) + self.rename_field.rect = Rect(cx, top + 42.0, field_w - 92.0, 32.0) + self.rename_button.rect = Rect(cx + field_w - 84.0, top + 42.0, 84.0, 32.0) + self.parent_dropdown.rect = Rect(cx, top + 104.0, field_w, 32.0) + self.volume_slider.rect = Rect(cx, top + 176.0, field_w, 24.0) + self.pan_slider.rect = Rect(cx, top + 238.0, field_w, 24.0) + self.mute_check.rect = Rect(cx, top + 280.0, 90.0, 28.0) + self.solo_check.rect = Rect(cx + 100.0, top + 280.0, 90.0, 28.0) + self.bypass_chain_check.rect = Rect(cx + 200.0, top + 280.0, 180.0, 28.0) + self.clear_peak_button.rect = Rect(cx, top + 394.0, 120.0, 30.0) + for index, field in enumerate(self.effect_parameter_fields): + column = index % 2 + row = index // 2 + field.rect = Rect(cx + column * 188.0, top + 484.0 + row * 58.0, 174.0, 32.0) + + rx = right_x + 12.0 + rw = self.RIGHT_WIDTH - 24.0 + self.preset_dropdown.rect = Rect(rx, top + 36.0, rw - 128.0, 32.0) + self.apply_preset_button.rect = Rect(rx + rw - 120.0, top + 36.0, 120.0, 32.0) + self.append_preset_check.rect = Rect(rx, top + 74.0, 110.0, 26.0) + + self.effect_list.rect = Rect(rx, top + 130.0, rw, 160.0) + self.add_effect_dropdown.rect = Rect(rx, top + 300.0, rw - 120.0, 32.0) + self.add_effect_button.rect = Rect(rx + rw - 112.0, top + 300.0, 112.0, 32.0) + self.remove_effect_button.rect = Rect(rx, top + 338.0, 136.0, 30.0) + self.effect_bypass_check.rect = Rect(rx + 148.0, top + 340.0, 160.0, 28.0) + self.effect_wet_slider.rect = Rect(rx, top + 398.0, rw, 22.0) + + self.send_list.rect = Rect(rx, top + 466.0, rw, 122.0) + self.send_target_dropdown.rect = Rect(rx, top + 598.0, rw - 110.0, 32.0) + self.add_send_button.rect = Rect(rx + rw - 102.0, top + 598.0, 102.0, 32.0) + self.send_amount_slider.rect = Rect(rx, top + 652.0, rw, 22.0) + self.send_pre_check.rect = Rect(rx, top + 684.0, 126.0, 28.0) + self.send_enabled_check.rect = Rect(rx + 132.0, top + 684.0, 110.0, 28.0) + self.remove_send_button.rect = Rect(rx + rw - 122.0, top + 682.0, 122.0, 30.0) + + self.save_button.rect = Rect(10.0, 12.0, 118.0, 32.0) + self.load_button.rect = Rect(136.0, 12.0, 118.0, 32.0) + self.reset_button.rect = Rect(262.0, 12.0, 92.0, 32.0) + return width, height + + def _controls(self) -> list[Control]: + return [ + self.bus_list, + self.add_bus_name, + self.add_bus_button, + self.remove_bus_button, + self.rename_field, + self.rename_button, + self.parent_dropdown, + self.volume_slider, + self.pan_slider, + self.mute_check, + self.solo_check, + self.bypass_chain_check, + self.clear_peak_button, + self.preset_dropdown, + self.apply_preset_button, + self.append_preset_check, + self.effect_list, + self.add_effect_dropdown, + self.add_effect_button, + self.remove_effect_button, + self.effect_bypass_check, + self.effect_wet_slider, + *self.effect_parameter_fields, + self.send_list, + self.send_target_dropdown, + self.add_send_button, + self.send_amount_slider, + self.send_pre_check, + self.send_enabled_check, + self.remove_send_button, + self.save_button, + self.load_button, + self.reset_button, + ] + + def update(self, delta_time: float) -> None: + del delta_time + self._layout() + mouse_x, mouse_y = self.game.input.mouse_position + for control in self._controls(): + if control.update(self.game.input, mouse_x, mouse_y): + break + + def _draw_meter(self, renderer, viewport: tuple[float, float], rect: Rect, value: float, peak: float, clipped: bool) -> None: + draw_rect(renderer, rect, self.theme.input, viewport, radius=3.0) + draw_outline(renderer, rect, self.theme.border, viewport) + fraction = max(0.0, min(1.0, float(value))) + fill = Rect(rect.x + 2.0, rect.y + 2.0, (rect.width - 4.0) * fraction, rect.height - 4.0) + if fill.width > 0: + draw_rect(renderer, fill, self.theme.error if clipped else self.theme.accent, viewport, radius=2.0) + hold_x = rect.x + 2.0 + (rect.width - 4.0) * max(0.0, min(1.0, peak)) + draw_rect(renderer, Rect(hold_x, rect.y + 1.0, 2.0, rect.height - 2.0), self.theme.warning, viewport) + + def render(self, interpolation: float) -> None: + del interpolation + width, height = self._layout() + viewport = (width, height) + renderer = self.game.renderer + draw_rect(renderer, Rect(0, 0, width, height), self.theme.background, viewport) + with renderer.overlay_scope(): + draw_rect(renderer, Rect(0, 0, width, self.TOOLBAR_HEIGHT), self.theme.panel, viewport) + draw_rect(renderer, Rect(0, height - self.STATUS_HEIGHT, width, self.STATUS_HEIGHT), self.theme.panel, viewport) + draw_rect(renderer, Rect(10, self.TOOLBAR_HEIGHT + 10, self.LEFT_WIDTH, height - self.TOOLBAR_HEIGHT - self.STATUS_HEIGHT - 20), self.theme.panel, viewport) + draw_rect(renderer, Rect(width - self.RIGHT_WIDTH - 10, self.TOOLBAR_HEIGHT + 10, self.RIGHT_WIDTH, height - self.TOOLBAR_HEIGHT - self.STATUS_HEIGHT - 20), self.theme.panel, viewport) + + for button in (self.save_button, self.load_button, self.reset_button): + button.render(renderer, viewport, self.theme) + draw_text(renderer, "Nexora Audio Mixer", width - 18.0, 17.0, viewport, scale=0.70, align="right") + draw_text(renderer, "Buses", 20.0, self.TOOLBAR_HEIGHT + 18.0, viewport, scale=0.72) + self.bus_list.render(renderer, viewport, self.theme) + self.add_bus_name.render(renderer, viewport, self.theme) + self.add_bus_button.render(renderer, viewport, self.theme) + self.remove_bus_button.render(renderer, viewport, self.theme) + + bus = self._selected_bus() + center_x = self.LEFT_WIDTH + 34.0 + if bus is not None: + draw_text(renderer, bus.name, center_x, self.TOOLBAR_HEIGHT + 22.0, viewport, scale=0.92) + draw_text(renderer, f"ID {bus.id[:12]}", center_x, self.TOOLBAR_HEIGHT + 58.0, viewport, scale=0.46, color=self.theme.muted) + draw_text(renderer, "Parent", center_x, self.TOOLBAR_HEIGHT + 94.0, viewport, scale=0.48, color=self.theme.muted) + draw_text(renderer, f"Volume {bus.volume:.2f}", center_x, self.TOOLBAR_HEIGHT + 158.0, viewport, scale=0.50) + draw_text(renderer, f"Pan {bus.pan:+.2f}", center_x, self.TOOLBAR_HEIGHT + 220.0, viewport, scale=0.50) + self.rename_field.render(renderer, viewport, self.theme) + self.rename_button.render(renderer, viewport, self.theme) + self.parent_dropdown.render(renderer, viewport, self.theme) + self.volume_slider.render(renderer, viewport, self.theme) + self.pan_slider.render(renderer, viewport, self.theme) + self.mute_check.render(renderer, viewport, self.theme) + self.solo_check.render(renderer, viewport, self.theme) + self.bypass_chain_check.render(renderer, viewport, self.theme) + + peak_l, peak_r = bus.peak + hold_l, hold_r = bus.peak_hold + peak_db_l, peak_db_r = bus.peak_dbfs + meter_y = self.TOOLBAR_HEIGHT + 335.0 + draw_text(renderer, "Meter L", center_x, meter_y - 20.0, viewport, scale=0.46, color=self.theme.muted) + self._draw_meter(renderer, viewport, Rect(center_x, meter_y, 300.0, 18.0), peak_l, hold_l, bus.clipped) + draw_text(renderer, _dbfs_label(peak_db_l), center_x + 310.0, meter_y - 1.0, viewport, scale=0.42, color=self.theme.muted) + draw_text(renderer, "Meter R", center_x, meter_y + 26.0, viewport, scale=0.46, color=self.theme.muted) + self._draw_meter(renderer, viewport, Rect(center_x, meter_y + 46.0, 300.0, 18.0), peak_r, hold_r, bus.clipped) + draw_text(renderer, _dbfs_label(peak_db_r), center_x + 310.0, meter_y + 45.0, viewport, scale=0.42, color=self.theme.muted) + self.clear_peak_button.render(renderer, viewport, self.theme) + if self._effect_parameter_names: + draw_text(renderer, "Selected effect parameters", center_x, self.TOOLBAR_HEIGHT + 454.0, viewport, scale=0.54) + for index, parameter in enumerate(self._effect_parameter_names): + field = self.effect_parameter_fields[index] + draw_text(renderer, parameter, field.rect.x, field.rect.y - 18.0, viewport, scale=0.42, color=self.theme.muted) + field.render(renderer, viewport, self.theme) + + right_x = width - self.RIGHT_WIDTH + draw_text(renderer, "Preset", right_x + 14.0, self.TOOLBAR_HEIGHT + 18.0, viewport, scale=0.62) + self.preset_dropdown.render(renderer, viewport, self.theme) + self.apply_preset_button.render(renderer, viewport, self.theme) + self.append_preset_check.render(renderer, viewport, self.theme) + draw_text(renderer, "DSP Effects", right_x + 14.0, self.TOOLBAR_HEIGHT + 112.0, viewport, scale=0.62) + self.effect_list.render(renderer, viewport, self.theme) + self.add_effect_dropdown.render(renderer, viewport, self.theme) + self.add_effect_button.render(renderer, viewport, self.theme) + self.remove_effect_button.render(renderer, viewport, self.theme) + self.effect_bypass_check.render(renderer, viewport, self.theme) + draw_text(renderer, f"Wet {self.effect_wet_slider.value:.0%}", right_x + 14.0, self.TOOLBAR_HEIGHT + 382.0, viewport, scale=0.46, color=self.theme.muted) + self.effect_wet_slider.render(renderer, viewport, self.theme) + + draw_text(renderer, "Sends", right_x + 14.0, self.TOOLBAR_HEIGHT + 446.0, viewport, scale=0.62) + self.send_list.render(renderer, viewport, self.theme) + self.send_target_dropdown.render(renderer, viewport, self.theme) + self.add_send_button.render(renderer, viewport, self.theme) + draw_text(renderer, f"Amount {self.send_amount_slider.value:.2f}", right_x + 14.0, self.TOOLBAR_HEIGHT + 636.0, viewport, scale=0.46, color=self.theme.muted) + self.send_amount_slider.render(renderer, viewport, self.theme) + self.send_pre_check.render(renderer, viewport, self.theme) + self.send_enabled_check.render(renderer, viewport, self.theme) + self.remove_send_button.render(renderer, viewport, self.theme) + + draw_text(renderer, self.status, 12.0, height - self.STATUS_HEIGHT + 6.0, viewport, scale=0.50, color=self.theme.muted) + draw_text(renderer, str(self.snapshot_path), width - 12.0, height - self.STATUS_HEIGHT + 6.0, viewport, scale=0.43, color=self.theme.muted, align="right") + + +class AudioMixerEditorApp(Game): + def __init__(self, *, project_path: str | Path | None = None) -> None: + self.audio_project_path = resolve_project_path(project_path) + self.audio_project_context = EditorProjectContext.from_path(self.audio_project_path) + super().__init__( + project_name="NexoraStandaloneAudioMixerEditor", + title=f"Nexora Audio Mixer - {self.audio_project_path.name}", + width=1500, + height=900, + resizable=True, + editor_mode=True, + ) + self.editor_scene = None + + def initialize(self) -> None: + if self.engine is not None: + self.engine.assets.root = self.audio_project_context.assets_root + icon_path = self.audio_project_context.icon_path + if self.window is not None and icon_path.is_file(): + try: + self.window.set_icon(icon_path) + except Exception: + pass + self.editor_scene = AudioMixerEditorScene( + self, + self.audio_project_path, + self.audio_project_context, + ) + self.scene = self.editor_scene + + +def run_audio_mixer_editor(project_path: str | Path | None = None) -> int: + try: + AudioMixerEditorApp(project_path=project_path).run() + except (FileNotFoundError, NotADirectoryError) as exc: + print(f"[Nexora Audio Mixer] {exc}") + return 2 + return 0 + + +__all__ = ["AudioMixerEditorApp", "AudioMixerEditorScene", "AudioSlider", "run_audio_mixer_editor"] diff --git a/nexora/editor/commands/tilemap_commands.py b/nexora/editor/commands/tilemap_commands.py index d8cf84a..763dc2d 100644 --- a/nexora/editor/commands/tilemap_commands.py +++ b/nexora/editor/commands/tilemap_commands.py @@ -18,7 +18,7 @@ class TileMapCellEdit: class TileMapAssetCommand: - """Undoable assignment of a portable ``.tilemap.net`` asset.""" + """Undoable assignment of a portable ``.ntmap`` asset.""" def __init__(self, node, asset_path: str, assets) -> None: self.node = node diff --git a/nexora/editor/cutscene_editor.py b/nexora/editor/cutscene_editor.py index 5c65159..342b7d8 100644 --- a/nexora/editor/cutscene_editor.py +++ b/nexora/editor/cutscene_editor.py @@ -6,7 +6,7 @@ from pathlib import Path from nexora import Game -from nexora.audio import AudioChannel, AudioSource +from nexora.audio import AudioSource from nexora.cutscene import ( CUTSCENE_ASSET_SUFFIX, CutsceneAsset, @@ -17,7 +17,8 @@ TRACK_TYPES, ) from nexora.editor.app import resolve_project_path -from nexora.editor.standalone_ui import ( +from nexora.editor.model import EditorProjectContext +from nexora.editor.ui import ( Button, CheckBox, Dropdown, @@ -26,25 +27,20 @@ Rect, TextField, UITheme, + FileBrowserModel, + centered_rect, + close_other_menus, draw_outline, draw_rect, draw_text, rgba, + sync_browser_list, ) from nexora.scene import Scene IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg", ".bmp", ".webp") AUDIO_EXTENSIONS = (".wav", ".ogg", ".mp3", ".flac") -HIDDEN_DIRECTORIES = { - ".git", - ".venv", - "__pycache__", - "build", - "dist", - "Bruch", - "MyGame", -} DEFAULT_SIGNING_KEY = ( b"nexora-default-save-signing-key-" b"change-this-for-your-game" @@ -72,10 +68,12 @@ def __init__( game, project_path: Path, cutscene_path: str | Path | None = None, + project_context: EditorProjectContext | None = None, ) -> None: super().__init__("StandaloneCutsceneEditor") self.game = game - self.project_path = Path(project_path).resolve() + self.project_context = project_context or EditorProjectContext.from_path(project_path) + self.project_path = self.project_context.root self.requested_cutscene_path = Path(cutscene_path).expanduser() if cutscene_path is not None else None self.assets_root = self._asset_root() self.theme = UITheme() @@ -91,6 +89,7 @@ def __init__( self.browser_root = self.project_path self.browser_path = self.project_path self.browser_entries: list[Path] = [] + self.file_browser = FileBrowserModel() self.preview_texture = None self.preview_reference = "" self.preview_error = "" @@ -208,52 +207,22 @@ def __init__( # ------------------------------------------------------------------ def _asset_root(self) -> Path: - cutscene_assets = [] - if self.requested_cutscene_path is not None: - requested = self.requested_cutscene_path - if not requested.is_absolute(): - requested = self.project_path / requested - requested = requested.resolve() - for parent in requested.parents: - if parent.name.lower() == "mygame": - cutscene_assets.append(parent / "assets") - break - candidates = ( - *cutscene_assets, - self.project_path / "assets", - self.project_path / "MyGame" / "assets", - ) - return next((path for path in candidates if path.is_dir()), self.project_path) + return self.project_context.asset_root_for(self.requested_cutscene_path) def _asset_relative(self, path: Path) -> str: - try: - return path.resolve().relative_to(self.assets_root.resolve()).as_posix() - except ValueError: - return path.name + return self.project_context.relative_asset(path, root=self.assets_root) def _resolve_asset_path(self, value: str | Path) -> Path: - path = Path(value).expanduser() - if path.is_absolute(): - return path - candidates = ( - self.assets_root / path, - self.project_path / path, - self.project_path / "MyGame" / "assets" / path, - Path.cwd() / path, - Path.cwd() / "MyGame" / "assets" / path, + return self.project_context.resolve_asset( + value, + reference=self.requested_cutscene_path, ) - return next((candidate for candidate in candidates if candidate.is_file()), candidates[0]) def _resolve_requested_path(self, value: str | Path) -> Path: - path = Path(value).expanduser() - if path.is_absolute(): - return path - candidates = ( - self.project_path / path, - self.project_path / "cutscenes" / path, - Path.cwd() / path, + return self.project_context.resolve_project_file( + value, + roots=(self.project_context.cutscenes_root,), ) - return next((candidate for candidate in candidates if candidate.is_file()), candidates[0]) def _signing_key(self): return getattr(self.game, "_scene_signing_key", DEFAULT_SIGNING_KEY) @@ -683,14 +652,10 @@ def _resolve_audio_path(self, value: str | Path) -> Path: if normalized_lower.startswith(prefix): normalized = normalized[len(prefix):] break - candidates = ( - self.assets_root / normalized, - self.project_path / normalized, - self.project_path / "MyGame" / "assets" / normalized, - Path.cwd() / normalized, - Path.cwd() / "MyGame" / "assets" / normalized, + return self.project_context.resolve_asset( + normalized, + reference=self.requested_cutscene_path, ) - return next((candidate for candidate in candidates if candidate.is_file()), candidates[0]) def _audio_reference(self, value: str) -> str: return str(self._resolve_audio_path(value).resolve()) @@ -758,7 +723,7 @@ def _play_editor_audio(self, value: object, *, offset: float = 0.0) -> None: return channel_name = str(value.get("channel", "sfx")).lower() - channel = next((item for item in AudioChannel if item.value == channel_name), AudioChannel.SFX) + bus = channel_name or "Master" bus_name = str(value.get("bus", channel_name.title())) try: bus = audio.get_bus(bus_name) @@ -766,7 +731,6 @@ def _play_editor_audio(self, value: object, *, offset: float = 0.0) -> None: bus = None source = AudioSource( sound, - channel=channel, volume=max(0.0, min(1.0, _float(value.get("volume", 1.0), 1.0))), loop=bool(value.get("loop", False)), bus=bus, @@ -945,56 +909,30 @@ def _open_browser(self, mode: str) -> None: self.browser_action_button.visible = mode == "save" self.browser_root = self.project_path if mode in {"image", "audio"}: - self.browser_path = self.assets_root + start = self.assets_root elif mode == "save" and (self.project_path / "cutscenes").is_dir(): - self.browser_path = self.project_path / "cutscenes" + start = self.project_path / "cutscenes" else: - self.browser_path = self.project_path + start = self.project_path + extensions = { + "image": IMAGE_EXTENSIONS, + "audio": AUDIO_EXTENSIONS, + }.get(mode, (CUTSCENE_ASSET_SUFFIX,)) + self.file_browser.open(self.project_path, start=start, extensions=extensions) + self.browser_path = self.file_browser.path self.browser_name.set_text(f"{self.document_name}{CUTSCENE_ASSET_SUFFIX}" if mode == "save" else "") self._refresh_browser() def _refresh_browser(self) -> None: - try: - entries = list(self.browser_path.iterdir()) - except (OSError, FileNotFoundError): - entries = [] - directories = sorted( - (item for item in entries if item.is_dir() and item.name not in HIDDEN_DIRECTORIES), - key=lambda item: item.name.lower(), - ) - if self.browser_mode in {"image"}: - extensions = IMAGE_EXTENSIONS - elif self.browser_mode == "audio": - extensions = AUDIO_EXTENSIONS - else: - extensions = (CUTSCENE_ASSET_SUFFIX,) - files = sorted( - (item for item in entries if item.is_file() and item.name.lower().endswith(extensions)), - key=lambda item: item.name.lower(), - ) - self.browser_entries = [] - labels: list[str] = [] - if self.browser_path != self.browser_root: - self.browser_entries.append(self.browser_path.parent) - labels.append("[..]") - self.browser_entries.extend(directories) - labels.extend(f"[{item.name}]" for item in directories) - self.browser_entries.extend(files) - labels.extend(item.name for item in files) - self.browser_list.set_items(labels) - self.browser_list.selected = -1 + self.file_browser.refresh() + self.browser_path = self.file_browser.path + self.browser_entries = list(self.file_browser.entries) + sync_browser_list(self.file_browser, self.browser_list) def _browser_selected(self, index: int) -> None: - if not 0 <= index < len(self.browser_entries): - return - if self.browser_path != self.browser_root and index == 0: - self.browser_path = self.browser_path.parent - self._refresh_browser() - return - path = self.browser_entries[index] - if path.is_dir(): - self.browser_path = path.resolve() - self._refresh_browser() + path = self.file_browser.select(index) + self._refresh_browser() + if path is None: return if self.browser_mode == "open": self._load_asset(path) @@ -1028,9 +966,7 @@ def _close_modal(self) -> None: self.status = "Ready" def _menu_opened(self, opened: Menu) -> None: - for menu in self.menus: - if menu is not opened: - menu.open = False + close_other_menus(self.menus, opened) def _show_help(self) -> None: self.modal = "info" @@ -1387,7 +1323,7 @@ def _render_browser(self, renderer, viewport_size: tuple[float, float]) -> None: def _render_info(self, renderer, viewport_size: tuple[float, float]) -> None: width, height = viewport_size draw_rect(renderer, Rect(0, 0, width, height), (0, 0, 0, 175), viewport_size) - window = Rect(width / 2.0 - 360.0, height / 2.0 - 170.0, 720.0, 340.0) + window = centered_rect(viewport_size, 720.0, 340.0) draw_rect(renderer, window, self.theme.panel, viewport_size, radius=6.0) draw_outline(renderer, window, self.theme.border, viewport_size) draw_text(renderer, self.info_title, window.x + 22.0, window.y + 20.0, viewport_size, scale=0.86) @@ -1417,6 +1353,7 @@ class CutsceneEditorApp(Game): def __init__(self, *, project_path: str | Path | None = None, cutscene_path: str | Path | None = None) -> None: self.cutscene_project_path = resolve_project_path(project_path) + self.cutscene_project_context = EditorProjectContext.from_path(self.cutscene_project_path) self.cutscene_asset_path = cutscene_path super().__init__( project_name="NexoraStandaloneCutsceneEditor", @@ -1430,32 +1367,23 @@ def __init__(self, *, project_path: str | Path | None = None, cutscene_path: str def initialize(self) -> None: if self.engine is not None: - assets_candidates = [self.cutscene_project_path / "assets"] - if self.cutscene_asset_path is not None: - requested = Path(self.cutscene_asset_path).expanduser() - if not requested.is_absolute(): - requested = self.cutscene_project_path / requested - requested = requested.resolve() - mygame_parts = [index for index, part in enumerate(requested.parts) if part.lower() == "mygame"] - if mygame_parts: - mygame_index = mygame_parts[0] - assets_candidates.insert( - 0, - Path(*requested.parts[:mygame_index + 1]) / "assets", - ) - assets_candidates.append(self.cutscene_project_path / "MyGame" / "assets") - assets_root = next( - (path for path in assets_candidates if path.is_dir()), - self.cutscene_project_path, + self.engine.assets.root = self.cutscene_project_context.asset_root_for( + self.cutscene_asset_path ) - self.engine.assets.root = assets_root - icon_path = self.engine.assets.root / "icon.png" if self.engine is not None else None + icon_path = self.cutscene_project_context.asset_root_for( + self.cutscene_asset_path + ) / "icon.png" if self.window is not None and icon_path is not None and icon_path.is_file(): try: self.window.set_icon(icon_path) except Exception: pass - scene = CutsceneEditorScene(self, self.cutscene_project_path, self.cutscene_asset_path) + scene = CutsceneEditorScene( + self, + self.cutscene_project_path, + self.cutscene_asset_path, + self.cutscene_project_context, + ) self.editor_scene = scene self.scene = scene diff --git a/nexora/editor/item_editor.py b/nexora/editor/item_editor.py index e2f2573..e8982b6 100644 --- a/nexora/editor/item_editor.py +++ b/nexora/editor/item_editor.py @@ -6,7 +6,8 @@ from nexora import Game from nexora.editor.app import resolve_project_path -from nexora.editor.standalone_ui import ( +from nexora.editor.model import EditorProjectContext +from nexora.editor.ui import ( Button, CategorizedListBox, CheckBox, @@ -16,18 +17,19 @@ Rect, TextField, UITheme, + FileBrowserModel, + centered_rect, + close_other_menus, draw_outline, draw_rect, draw_text, + sync_browser_list, ) from nexora.items import ITEM_ASSET_SUFFIX, ItemAsset, ItemDefinition from nexora.scene import Scene IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg", ".bmp", ".webp") -ITEM_DIRECTORIES = {".git", ".venv", "__pycache__", "build", "dist", "Bruch", "MyGame"} - - def _number(value: str, default: float = 0.0) -> float: try: return float(value.strip()) @@ -50,12 +52,19 @@ class ItemEditorScene(Scene): LEFT_WIDTH = 300.0 PREVIEW_WIDTH = 320.0 - def __init__(self, game, project_path: Path, item_path: str | Path | None = None) -> None: + def __init__( + self, + game, + project_path: Path, + item_path: str | Path | None = None, + project_context: EditorProjectContext | None = None, + ) -> None: super().__init__("StandaloneItemEditor") self.game = game - self.project_path = Path(project_path).resolve() - self.assets_root = self._asset_root() - self.items_root = self.project_path / "items" + self.project_context = project_context or EditorProjectContext.from_path(project_path) + self.project_path = self.project_context.root + self.assets_root = self.project_context.assets_root + self.items_root = self.project_context.items_root self.theme = UITheme() self.item = ItemDefinition() self.document_path: Path | None = None @@ -66,6 +75,7 @@ def __init__(self, game, project_path: Path, item_path: str | Path | None = None self.browser_root = self.project_path self.browser_path = self._item_start_path() self.browser_entries: list[Path] = [] + self.file_browser = FileBrowserModel() self.icon_texture = None self._layout_size = (-1.0, -1.0) self._loading = False @@ -216,33 +226,25 @@ def __init__(self, game, project_path: Path, item_path: str | Path | None = None # ------------------------------------------------------------------ def _asset_root(self) -> Path: - path = self.project_path / "assets" - return path if path.is_dir() else self.project_path + return self.project_context.assets_root def _item_start_path(self) -> Path: - return self.items_root if self.items_root.is_dir() else self.project_path + return self.project_context.items_root if self.items_root.is_dir() else self.project_path def _asset_relative(self, path: Path) -> str: - try: - return path.resolve().relative_to(self.assets_root.resolve()).as_posix() - except ValueError: - return path.name + return self.project_context.relative_asset(path, root=self.assets_root) def _asset_path(self, value: str | Path) -> Path: - path = Path(value) - return path if path.is_absolute() else self.assets_root / path + return self.project_context.resolve_asset(value) def _resolve_requested_path(self, value: str | Path) -> Path: path = Path(value).expanduser() if path.is_absolute(): return path - candidates = ( - self.project_path / path, - self.items_root / path, - self.assets_root / path, - Path.cwd() / path, + return self.project_context.resolve_project_file( + path, + roots=(self.items_root, self.assets_root), ) - return next((candidate for candidate in candidates if candidate.is_file()), candidates[0]) # ------------------------------------------------------------------ # Item state @@ -551,10 +553,7 @@ def save_item(self) -> None: self.status = f"Could not save item: {exc}" def _project_relative(self, path: Path) -> str: - try: - return path.resolve().relative_to(self.project_path.resolve()).as_posix() - except ValueError: - return str(path) + return self.project_context.relative_asset(path, root=self.project_path) def _validation_problems(self, item: ItemDefinition | None = None) -> list[str]: item = item or self._collect_item() @@ -650,43 +649,27 @@ def _open_browser(self, mode: str) -> None: self.modal = "browser" self.browser_mode = mode self.browser_root = self.project_path - self.browser_path = self._item_start_path() if mode in {"open", "save"} else self.assets_root + start = self._item_start_path() if mode in {"open", "save"} else self.assets_root + extensions = ( + (ITEM_ASSET_SUFFIX,) + if mode in {"open", "save"} + else IMAGE_EXTENSIONS + ) + self.file_browser.open(self.project_path, start=start, extensions=extensions) + self.browser_path = self.file_browser.path self.browser_name.set_text(f"{self.item.item_id}{ITEM_ASSET_SUFFIX}" if mode == "save" else "") self._refresh_browser() def _refresh_browser(self) -> None: - try: - entries = list(self.browser_path.iterdir()) - except (OSError, FileNotFoundError): - entries = [] - directories = sorted((entry for entry in entries if entry.is_dir() and entry.name not in ITEM_DIRECTORIES), key=lambda path: path.name.lower()) - if self.browser_mode in {"open", "save"}: - files = sorted((entry for entry in entries if entry.is_file() and entry.name.lower().endswith(ITEM_ASSET_SUFFIX)), key=lambda path: path.name.lower()) - else: - files = sorted((entry for entry in entries if entry.is_file() and entry.suffix.lower() in IMAGE_EXTENSIONS), key=lambda path: path.name.lower()) - self.browser_entries = [] - labels: list[str] = [] - if self.browser_path != self.browser_root: - self.browser_entries.append(self.browser_path.parent) - labels.append("[..]") - self.browser_entries.extend(directories) - labels.extend(f"[{entry.name}]" for entry in directories) - self.browser_entries.extend(files) - labels.extend(entry.name for entry in files) - self.browser_list.set_items(labels) - self.browser_list.selected = -1 + self.file_browser.refresh() + self.browser_path = self.file_browser.path + self.browser_entries = list(self.file_browser.entries) + sync_browser_list(self.file_browser, self.browser_list) def _browser_selected(self, index: int) -> None: - if not 0 <= index < len(self.browser_entries): - return - if self.browser_path != self.browser_root and index == 0: - self.browser_path = self.browser_path.parent - self._refresh_browser() - return - path = self.browser_entries[index] - if path.is_dir(): - self.browser_path = path.resolve() - self._refresh_browser() + path = self.file_browser.select(index) + self._refresh_browser() + if path is None: return if self.browser_mode == "icon": self.fields["icon"].set_text(self._asset_relative(path)) @@ -733,9 +716,7 @@ def _load_icon_texture(self) -> None: # ------------------------------------------------------------------ def _menu_opened(self, opened: Menu) -> None: - for menu in self.menus: - if menu is not opened: - menu.open = False + close_other_menus(self.menus, opened) def _show_about(self) -> None: self.info_title = "Nexora Item Assets" @@ -910,7 +891,7 @@ def _layout_modal_controls(self, width: float, height: float) -> None: self.browser_cancel_button.rect = Rect(inner_right - 100.0, footer_y, 100.0, 32.0) self.browser_save_button.rect = Rect(inner_right - 100.0, footer_y, 100.0, 32.0) self.browser_name.rect = Rect(inner_x, footer_y, max(180.0, self.browser_cancel_button.rect.x - inner_x - 14.0), 32.0) - info_window = Rect(width / 2.0 - 330.0, height / 2.0 - 170.0, 660.0, 340.0) + info_window = centered_rect((width, height), 660.0, 340.0) self.info_close_button.rect = Rect(info_window.x + info_window.width - 126.0, info_window.y + info_window.height - 52.0, 108.0, 32.0) def _update_controls(self, controls: list, mouse_x: float, mouse_y: float) -> None: @@ -1039,7 +1020,7 @@ def _render_browser(self, renderer, viewport_size: tuple[float, float]) -> None: def _render_info(self, renderer, viewport_size: tuple[float, float]) -> None: width, height = viewport_size draw_rect(renderer, Rect(0, 0, width, height), (0, 0, 0, 175), viewport_size) - window = Rect(width / 2.0 - 330.0, height / 2.0 - 170.0, 660.0, 340.0) + window = centered_rect(viewport_size, 660.0, 340.0) draw_rect(renderer, window, self.theme.panel, viewport_size, radius=6.0) draw_outline(renderer, window, self.theme.border, viewport_size) draw_text(renderer, self.info_title, window.x + 22.0, window.y + 20.0, viewport_size, scale=0.86) @@ -1071,6 +1052,7 @@ class ItemEditorApp(Game): def __init__(self, *, project_path: str | Path | None = None, item_path: str | Path | None = None) -> None: self.item_project_path = resolve_project_path(project_path) + self.item_project_context = EditorProjectContext.from_path(self.item_project_path) self.item_asset_path = item_path super().__init__( project_name="NexoraStandaloneItemEditor", @@ -1084,15 +1066,19 @@ def __init__(self, *, project_path: str | Path | None = None, item_path: str | P def initialize(self) -> None: if self.engine is not None: - assets_root = self.item_project_path / "assets" - self.engine.assets.root = assets_root if assets_root.is_dir() else self.item_project_path - icon_path = self.item_project_path / "assets" / "icon.png" + self.engine.assets.root = self.item_project_context.assets_root + icon_path = self.item_project_context.icon_path if self.window is not None and icon_path.is_file(): try: self.window.set_icon(icon_path) except Exception: pass - self.editor_scene = ItemEditorScene(self, self.item_project_path, self.item_asset_path) + self.editor_scene = ItemEditorScene( + self, + self.item_project_path, + self.item_asset_path, + self.item_project_context, + ) self.scene = self.editor_scene diff --git a/nexora/editor/model/__init__.py b/nexora/editor/model/__init__.py index 2f26f72..3245bc1 100644 --- a/nexora/editor/model/__init__.py +++ b/nexora/editor/model/__init__.py @@ -1,9 +1,10 @@ from .document import EditorDocument, SceneTreeEntry -from .project import ProjectEntry, ProjectModel +from .project import EditorProjectContext, ProjectEntry, ProjectModel from .selection import SelectionService __all__ = [ "EditorDocument", + "EditorProjectContext", "ProjectEntry", "ProjectModel", "SceneTreeEntry", diff --git a/nexora/editor/model/project.py b/nexora/editor/model/project.py index 2ef0dab..3dae30a 100644 --- a/nexora/editor/model/project.py +++ b/nexora/editor/model/project.py @@ -2,6 +2,114 @@ from dataclasses import dataclass from pathlib import Path +from typing import Iterable + + +@dataclass(slots=True, frozen=True) +class EditorProjectContext: + """Canonical paths shared by every Nexora editor.""" + + root: Path + assets_root: Path + items_root: Path + cutscenes_root: Path + scenes_root: Path + prefabs_root: Path + + @classmethod + def from_path(cls, root: str | Path) -> "EditorProjectContext": + project_root = Path(root).expanduser().resolve() + if not project_root.is_dir(): + raise NotADirectoryError( + f"Project root is not a directory: {project_root}" + ) + assets_root = next( + ( + candidate + for candidate in ( + project_root / "assets", + project_root / "MyGame" / "assets", + ) + if candidate.is_dir() + ), + project_root, + ) + return cls( + root=project_root, + assets_root=assets_root, + items_root=project_root / "items", + cutscenes_root=project_root / "cutscenes", + scenes_root=project_root / "scenes", + prefabs_root=project_root / "prefabs", + ) + + @property + def name(self) -> str: + return self.root.name + + @property + def icon_path(self) -> Path: + return self.assets_root / "icon.png" + + def asset_root_for(self, reference: str | Path | None = None) -> Path: + """Return the asset root that belongs to a project-relative reference.""" + + candidates: list[Path] = [] + if reference is not None: + path = Path(reference).expanduser() + if not path.is_absolute(): + path = self.root / path + path = path.resolve() + for parent in (path, *path.parents): + if parent.name.lower() == "mygame": + candidates.append(parent / "assets") + break + candidates.extend((self.root / "assets", self.root / "MyGame" / "assets")) + return next((candidate for candidate in candidates if candidate.is_dir()), self.root) + + def relative_asset(self, path: str | Path, *, root: str | Path | None = None) -> str: + candidate = Path(path).expanduser().resolve() + asset_root = Path(root).resolve() if root is not None else self.assets_root.resolve() + try: + return candidate.relative_to(asset_root).as_posix() + except ValueError: + try: + return candidate.relative_to(self.root).as_posix() + except ValueError: + return candidate.name + + def resolve_asset( + self, + value: str | Path, + *, + reference: str | Path | None = None, + ) -> Path: + path = Path(value).expanduser() + if path.is_absolute(): + return path + asset_root = self.asset_root_for(reference) + candidates = ( + asset_root / path, + self.root / path, + self.root / "assets" / path, + self.root / "MyGame" / "assets" / path, + ) + return next((candidate for candidate in candidates if candidate.is_file()), candidates[0]) + + def resolve_project_file( + self, + value: str | Path, + *, + roots: Iterable[str | Path] = (), + ) -> Path: + path = Path(value).expanduser() + if path.is_absolute(): + return path + candidates = tuple(Path(root) / path for root in roots) + ( + self.root / path, + Path.cwd() / path, + ) + return next((candidate for candidate in candidates if candidate.is_file()), candidates[0]) @dataclass(slots=True, frozen=True) @@ -22,6 +130,8 @@ def __init__(self, root: str | Path) -> None: f"Project root is not a directory: {self.root}" ) + self.context = EditorProjectContext.from_path(self.root) + @property def name(self) -> str: return self.root.name diff --git a/nexora/editor/scene.py b/nexora/editor/scene.py index 87c620a..82ad041 100644 --- a/nexora/editor/scene.py +++ b/nexora/editor/scene.py @@ -25,6 +25,7 @@ from nexora.editor.model import ( EditorDocument, + EditorProjectContext, ProjectModel, SelectionService, ) @@ -97,11 +98,13 @@ def __init__( project: ProjectModel, document: EditorDocument, selection: SelectionService, + project_context: EditorProjectContext | None = None, ) -> None: super().__init__("NexoraEditor") self.game = game - self.project_path = Path(project_path).resolve() + self.project_context = project_context or EditorProjectContext.from_path(project_path) + self.project_path = self.project_context.root self.project = project self.document = document self.selection = selection @@ -445,7 +448,7 @@ def request_open_scene(self) -> None: self._run_after_dirty_check(self._open_scene_file_dialog) def _scene_start_directory(self) -> Path: - scenes = self.project_path / "scenes" + scenes = self.project_context.scenes_root if scenes.is_dir(): return scenes return self.project_path @@ -494,9 +497,9 @@ def save_selected_prefab(self) -> None: self.status_label.text = "Select a node to save as a prefab first" return - start = self.project_path / "prefabs" + start = self.project_context.prefabs_root if not start.is_dir(): - start = self.project_path / "assets" + start = self.project_context.assets_root if not start.is_dir(): start = self.project_path @@ -706,7 +709,7 @@ def _configure_default_tilemap_node(self, node: TileMapNode) -> None: ( candidate for candidate in candidates - if (self.project_path / "assets" / candidate).is_file() + if self.project_context.resolve_asset(candidate).is_file() ), None, ) diff --git a/nexora/editor/standalone_tilemap_editor.py b/nexora/editor/standalone_tilemap_editor.py index f7cdd3f..461ee39 100644 --- a/nexora/editor/standalone_tilemap_editor.py +++ b/nexora/editor/standalone_tilemap_editor.py @@ -6,7 +6,8 @@ from nexora import Game from nexora.editor.commands import CommandStack, TileMapCellEdit, TileMapPaintCommand -from nexora.editor.standalone_ui import ( +from nexora.editor.model import EditorProjectContext +from nexora.editor.ui import ( Button, CheckBox, Dropdown, @@ -15,10 +16,14 @@ Rect, TextField, UITheme, + FileBrowserModel, + centered_rect, + close_other_menus, draw_outline, draw_rect, draw_text, rgba, + sync_browser_list, ) from nexora.editor.app import resolve_project_path from nexora.scene import Scene @@ -36,10 +41,16 @@ class StandaloneTileMapEditorScene(Scene): BOTTOM_HEIGHT = 188.0 GAP = 1.0 - def __init__(self, game, project_path: Path) -> None: + def __init__( + self, + game, + project_path: Path, + project_context: EditorProjectContext | None = None, + ) -> None: super().__init__("StandaloneTileMapEditor") self.game = game - self.project_path = Path(project_path).resolve() + self.project_context = project_context or EditorProjectContext.from_path(project_path) + self.project_path = self.project_context.root self.theme = UITheme() self.viewport = Rect(0.0, 0.0, 1.0, 1.0) self.left_panel = Rect(0.0, 0.0, 1.0, 1.0) @@ -73,6 +84,7 @@ def __init__(self, game, project_path: Path) -> None: self.browser_root = self.project_path self.browser_path = self._asset_root() self.browser_entries: list[Path] = [] + self.file_browser = FileBrowserModel() self.browser_list = ListBox(Rect(0, 0, 1, 1), self._browser_selected) self.browser_cancel_button = Button(Rect(0, 0, 1, 1), "Cancel", self._close_modal) self.browser_save_button = Button(Rect(0, 0, 1, 1), "Save", self._save_from_browser) @@ -150,18 +162,13 @@ def __init__(self, game, project_path: Path) -> None: # ------------------------------------------------------------------ def _asset_root(self) -> Path: - path = self.project_path / "assets" - return path if path.is_dir() else self.project_path + return self.project_context.assets_root def _asset_relative(self, path: Path) -> str: - try: - return path.resolve().relative_to(self._asset_root().resolve()).as_posix() - except ValueError: - return path.name + return self.project_context.relative_asset(path, root=self._asset_root()) def _asset_path(self, relative: str | Path) -> Path: - path = Path(relative) - return path if path.is_absolute() else self._asset_root() / path + return self.project_context.resolve_asset(relative) # ------------------------------------------------------------------ # Documents @@ -244,46 +251,31 @@ def open_tileset_browser(self) -> None: def _open_browser(self, mode: str) -> None: self.modal = "browser" self.browser_mode = mode - # Start in assets, while allowing ``[..]`` to navigate back to the - # project root and then through its folders. self.browser_root = self.project_path - self.browser_path = self._asset_root() + extensions = ( + (".png", ".jpg", ".jpeg", ".bmp", ".webp") + if mode == "tileset" + else (TILEMAP_ASSET_SUFFIX,) + ) + self.file_browser.open( + self.project_path, + start=self._asset_root(), + extensions=extensions, + ) + self.browser_path = self.file_browser.path self.browser_name.set_text("") self._refresh_browser() def _refresh_browser(self) -> None: - try: - entries = list(self.browser_path.iterdir()) - except (OSError, FileNotFoundError): - entries = [] - directories = sorted((item for item in entries if item.is_dir() and item.name not in {".git", ".venv", "__pycache__", "build", "dist", "Bruch", "MyGame"}), key=lambda item: item.name.lower()) - extensions = (".png", ".jpg", ".jpeg", ".bmp", ".webp") if self.browser_mode == "tileset" else (TILEMAP_ASSET_SUFFIX,) - files = sorted((item for item in entries if item.is_file() and item.name.lower().endswith(extensions)), key=lambda item: item.name.lower()) - self.browser_entries = [] - labels: list[str] = [] - if self.browser_path != self.browser_root: - self.browser_entries.append(self.browser_path.parent) - labels.append("[..]") - self.browser_entries.extend(directories) - labels.extend(f"[{item.name}]" for item in directories) - self.browser_entries.extend(files) - labels.extend(item.name for item in files) - self.browser_list.set_items(labels) - self.browser_list.selected = -1 + self.file_browser.refresh() + self.browser_path = self.file_browser.path + self.browser_entries = list(self.file_browser.entries) + sync_browser_list(self.file_browser, self.browser_list) def _browser_selected(self, index: int) -> None: - if not (0 <= index < len(self.browser_entries)): - return - # The parent entry is the only navigation control in the browser. - # Handle it explicitly instead of relying on Path.is_dir(), so the - # ``[..]`` row remains reliable across platforms and path variants. - if self.browser_path != self.browser_root and index == 0: - self._browser_up() - return - path = self.browser_entries[index] - if path.is_dir(): - self.browser_path = path.resolve() - self._refresh_browser() + path = self.file_browser.select(index) + self._refresh_browser() + if path is None: return if self.browser_mode == "tileset": self._begin_tileset_setup(path) @@ -298,8 +290,7 @@ def _browser_selected(self, index: int) -> None: self._close_modal() def _browser_up(self) -> None: - if self.browser_path != self.browser_root: - self.browser_path = self.browser_path.parent + if self.file_browser.go_up(): self._refresh_browser() def _save_from_browser(self) -> None: @@ -496,9 +487,7 @@ def _move_layer_down(self) -> None: self.status = f"Layer moved down: {self.active_layer_name}" def _menu_opened(self, active_menu: Menu) -> None: - for menu in self.menus: - if menu is not active_menu: - menu.open = False + close_other_menus(self.menus, active_menu) def _quit_editor(self) -> None: self.game.stop() @@ -529,7 +518,7 @@ def _show_workflow_dialog(self) -> None: "2. Configure tile size, gap and margin", "3. Create and arrange layers", "4. Paint the map and assign layer roles", - "5. Save it as a .tilemap.net asset", + "5. Save it as a .ntmap asset", ), ) @@ -539,7 +528,7 @@ def _show_about_dialog(self) -> None: ( "Nexora Standalone TileMap Editor", "Version 0.10", - "A portable editor for Nexora .tilemap.net assets.", + "A portable editor for Nexora .ntmap assets.", ), ) @@ -696,6 +685,11 @@ def _layout(self) -> tuple[float, float]: width = float(self.game.renderer.width) height = float(self.game.renderer.height) if (width, height) == self._layout_size: + # Modal controls also depend on the current browser mode. The + # window size can stay unchanged while switching from the open + # dialog to Save As, so they must be refreshed even when the + # main layout is cached. + self._layout_modal_controls(width, height) return width, height self._layout_size = (width, height) body_top = self.TOOLBAR_HEIGHT @@ -782,7 +776,7 @@ def _layout_modal_controls(self, width: float, height: float) -> None: self.setup_fields[name].rect = Rect(x, y + 18.0, 300.0, 30.0) self.setup_cancel_button.rect = Rect(window.x + window.width - 244.0, window.y + window.height - 52.0, 108.0, 32.0) self.setup_apply_button.rect = Rect(window.x + window.width - 124.0, window.y + window.height - 52.0, 108.0, 32.0) - info_window = Rect(width / 2.0 - 330.0, height / 2.0 - 190.0, 660.0, 380.0) + info_window = centered_rect((width, height), 660.0, 380.0) self.info_close_button.rect = Rect(info_window.x + info_window.width - 126.0, info_window.y + info_window.height - 52.0, 108.0, 32.0) def _viewport_cell(self, mouse_x: float, mouse_y: float) -> tuple[int, int] | None: @@ -1165,6 +1159,7 @@ class StandaloneTileMapEditorApp(Game): def __init__(self, *, project_path: str | Path | None = None, tilemap_path: str | Path | None = None) -> None: self.tilemap_project_path = resolve_project_path(project_path) + self.tilemap_project_context = EditorProjectContext.from_path(self.tilemap_project_path) self.tilemap_asset_path = tilemap_path super().__init__( project_name="NexoraStandaloneTileMapEditor", @@ -1178,15 +1173,18 @@ def __init__(self, *, project_path: str | Path | None = None, tilemap_path: str def initialize(self) -> None: if self.engine is not None: - assets_root = self.tilemap_project_path / "assets" - self.engine.assets.root = assets_root if assets_root.is_dir() else self.tilemap_project_path - icon_path = self.tilemap_project_path / "assets" / "icon.png" + self.engine.assets.root = self.tilemap_project_context.assets_root + icon_path = self.tilemap_project_context.icon_path if self.window is not None and icon_path.is_file(): try: self.window.set_icon(icon_path) except Exception: pass - scene = StandaloneTileMapEditorScene(self, self.tilemap_project_path) + scene = StandaloneTileMapEditorScene( + self, + self.tilemap_project_path, + self.tilemap_project_context, + ) self.editor_scene = scene self.scene = scene if self.tilemap_asset_path is not None: @@ -1195,8 +1193,8 @@ def initialize(self) -> None: asset_path = raw_path else: candidates = ( - self.tilemap_project_path / "assets" / raw_path, - self.tilemap_project_path / raw_path, + self.tilemap_project_context.resolve_asset(raw_path), + self.tilemap_project_context.resolve_project_file(raw_path), Path.cwd() / raw_path, ) asset_path = next((candidate for candidate in candidates if candidate.is_file()), candidates[0]) diff --git a/nexora/editor/standalone_ui.py b/nexora/editor/standalone_ui.py index 2a6296f..55e8607 100644 --- a/nexora/editor/standalone_ui.py +++ b/nexora/editor/standalone_ui.py @@ -1,537 +1,9 @@ -from __future__ import annotations +"""Backward-compatible import facade for the shared editor UI package. -"""Small renderer-backed UI toolkit for the standalone TileMap editor. - -This module deliberately does not use Nexora's UINode tree. Controls use -top-left screen rectangles while the renderer itself remains in centered -coordinates. That keeps layout math explicit and makes the standalone -editor independent from the regular editor UI. +New editor code should import widgets from :mod:`nexora.editor.ui`. This +module remains intentionally tiny so integrations using the old import path +continue to work while the UI implementation has a single home. """ -from dataclasses import dataclass -from typing import Callable - - -Color = tuple[int, int, int, int] - -# Global readability multiplier for the standalone editor UI. Change this -# value once to adjust all labels, buttons and field text consistently. -UI_SCALE = 1.50 - - -@dataclass(slots=True) -class UITheme: - background: Color = (17, 19, 23, 255) - panel: Color = (29, 32, 38, 255) - panel_dark: Color = (22, 25, 30, 255) - border: Color = (61, 67, 78, 255) - input: Color = (35, 39, 47, 255) - input_focus: Color = (43, 49, 61, 255) - button: Color = (38, 43, 52, 255) - button_hover: Color = (52, 61, 76, 255) - accent: Color = (84, 142, 235, 255) - text: Color = (235, 237, 242, 255) - muted: Color = (154, 161, 174, 255) - warning: Color = (240, 174, 86, 255) - error: Color = (238, 112, 112, 255) - selected: Color = (50, 82, 125, 255) - - -@dataclass(slots=True) -class Rect: - x: float - y: float - width: float - height: float - - def contains(self, x: float, y: float) -> bool: - return ( - self.x <= float(x) <= self.x + self.width - and self.y <= float(y) <= self.y + self.height - ) - - def center(self) -> tuple[float, float]: - return (self.x + self.width / 2.0, self.y + self.height / 2.0) - - -def rgba(color: Color) -> tuple[float, float, float, float]: - return tuple(channel / 255.0 for channel in color) - - -def _world_point(x: float, y: float, width: float, height: float) -> tuple[float, float]: - return (float(x) - width / 2.0, float(y) - height / 2.0) - - -def draw_rect(renderer, rect: Rect, color: Color, viewport: tuple[float, float], *, radius: float = 0.0) -> None: - width, height = viewport - x, y = _world_point(rect.x + rect.width / 2.0, rect.y + rect.height / 2.0, width, height) - renderer.rect(x, y, rect.width, rect.height, color=rgba(color), radius=radius) - - -def draw_outline(renderer, rect: Rect, color: Color, viewport: tuple[float, float], *, width: float = 1.0) -> None: - screen_width, screen_height = viewport - left, top = _world_point(rect.x, rect.y, screen_width, screen_height) - right, bottom = _world_point(rect.x + rect.width, rect.y + rect.height, screen_width, screen_height) - renderer.line(left, top, right, top, width=width, color=rgba(color)) - renderer.line(right, top, right, bottom, width=width, color=rgba(color)) - renderer.line(right, bottom, left, bottom, width=width, color=rgba(color)) - renderer.line(left, bottom, left, top, width=width, color=rgba(color)) - - -def draw_text( - renderer, - text: str, - x: float, - y: float, - viewport: tuple[float, float], - *, - scale: float = 0.65, - color: Color = (235, 237, 242, 255), - align: str = "left", -) -> None: - if not text: - return - scale = float(scale) * UI_SCALE - width, height = viewport - lines = str(text).splitlines() or [""] - text_renderer = getattr(getattr(renderer, "gpu", None), "text_renderer", None) - previous = None - if text_renderer is not None: - previous = text_renderer.color - text_renderer.set_color(*rgba(color)) - try: - for index, line in enumerate(lines): - line_x = float(x) - if align != "left": - measured_width, _ = renderer.text_measure(line, scale=scale) - if align == "center": - line_x -= measured_width / 2.0 - elif align == "right": - line_x -= measured_width - world_x, world_y = _world_point(line_x, y, width, height) - renderer.text(line, world_x, world_y + renderer.text_baseline(scale=scale), scale=scale) - y += renderer.text_measure("Ag", scale=scale)[1] - finally: - if text_renderer is not None and previous is not None: - text_renderer.set_color(*previous) - - -class Control: - def __init__(self, rect: Rect) -> None: - self.rect = rect - self.visible = True - self.enabled = True - self.hovered = False - - def update(self, input_manager, mouse_x: float, mouse_y: float) -> bool: - self.hovered = self.visible and self.rect.contains(mouse_x, mouse_y) - return False - - def render(self, renderer, viewport: tuple[float, float], theme: UITheme) -> None: - del renderer, viewport, theme - - -class Button(Control): - def __init__(self, rect: Rect, text: str, on_click: Callable[[], None] | None = None) -> None: - super().__init__(rect) - self.text = str(text) - self.on_click = on_click - self.pressed = False - self.selected = False - self.text_scale = 0.62 - - def update(self, input_manager, mouse_x: float, mouse_y: float) -> bool: - super().update(input_manager, mouse_x, mouse_y) - self.pressed = bool(self.hovered and input_manager.mouse_down("left")) - if self.enabled and self.hovered and input_manager.mouse_pressed("left"): - if self.on_click is not None: - self.on_click() - return True - return False - - def render(self, renderer, viewport, theme: UITheme) -> None: - if not self.visible: - return - color = theme.selected if self.selected else (theme.button_hover if self.hovered else theme.button) - if not self.enabled: - color = theme.panel_dark - draw_rect(renderer, self.rect, color, viewport, radius=4.0) - draw_outline(renderer, self.rect, theme.accent if self.pressed else theme.border, viewport) - text_height = renderer.text_measure("Ag", scale=self.text_scale * UI_SCALE)[1] - draw_text( - renderer, - self.text, - self.rect.x + self.rect.width / 2.0, - self.rect.y + (self.rect.height - text_height) / 2.0, - viewport, - scale=self.text_scale, - color=theme.text if self.enabled else theme.muted, - align="center", - ) - - -class Menu(Control): - """Compact top-bar menu with a renderer-backed drop-down list.""" - - def __init__(self, rect: Rect, title: str, items: list[tuple[str, Callable[[], None]]]) -> None: - super().__init__(rect) - self.title = str(title) - self.items = [(str(label), callback) for label, callback in items] - self.open = False - self.item_height = 30.0 - self.menu_width = 220.0 - self.popup_origin: tuple[float, float] | None = None - self.on_open: Callable[["Menu"], None] | None = None - self._last_mouse = (-1.0, -1.0) - - @property - def dropdown_rect(self) -> Rect: - x, y = self.popup_origin or (self.rect.x, self.rect.y + self.rect.height) - return Rect(x, y, self.menu_width, self.item_height * len(self.items)) - - def update(self, input_manager, mouse_x: float, mouse_y: float) -> bool: - self._last_mouse = (float(mouse_x), float(mouse_y)) - self.hovered = self.visible and self.rect.contains(mouse_x, mouse_y) - if not self.visible or not input_manager.mouse_pressed("left"): - return False - if self.rect.contains(mouse_x, mouse_y): - self.open = not self.open - if self.open and self.on_open is not None: - self.on_open(self) - return True - if self.open: - dropdown = self.dropdown_rect - if dropdown.contains(mouse_x, mouse_y): - index = int((mouse_y - dropdown.y) // self.item_height) - if 0 <= index < len(self.items): - _label, callback = self.items[index] - self.open = False - callback() - return True - self.open = False - return True - return False - - def render(self, renderer, viewport, theme: UITheme) -> None: - if not self.visible: - return - draw_rect(renderer, self.rect, theme.button_hover if self.hovered or self.open else theme.button, viewport, radius=4.0) - draw_outline(renderer, self.rect, theme.accent if self.open else theme.border, viewport) - text_height = renderer.text_measure("Ag", scale=0.64 * UI_SCALE)[1] - draw_text(renderer, self.title, self.rect.x + self.rect.width / 2.0, self.rect.y + (self.rect.height - text_height) / 2.0, viewport, scale=0.64, align="center") - if not self.open: - return - dropdown = self.dropdown_rect - draw_rect(renderer, dropdown, theme.panel, viewport, radius=3.0) - draw_outline(renderer, dropdown, theme.accent, viewport) - for index, (label, _callback) in enumerate(self.items): - item = Rect(dropdown.x + 2.0, dropdown.y + index * self.item_height + 2.0, dropdown.width - 4.0, self.item_height - 4.0) - if item.contains(*self._last_mouse): - draw_rect(renderer, item, theme.selected, viewport, radius=2.0) - text_height = renderer.text_measure("Ag", scale=0.58 * UI_SCALE)[1] - draw_text(renderer, label, item.x + 10.0, item.y + (item.height - text_height) / 2.0, viewport, scale=0.58) - -class TextField(Control): - def __init__(self, rect: Rect, text: str = "", placeholder: str = "") -> None: - super().__init__(rect) - self.text = str(text) - self.placeholder = str(placeholder) - self.focused = False - self.caret = len(self.text) - self.on_submit: Callable[[str], None] | None = None - self.on_change: Callable[[str], None] | None = None - self.text_scale = 0.62 - - def set_text(self, text: str) -> None: - self.text = str(text) - self.caret = min(self.caret, len(self.text)) - - def focus(self, input_manager) -> None: - self.focused = True - try: - input_manager.acquire_text_input(self) - except Exception: - pass - - def blur(self, input_manager) -> None: - if not self.focused: - return - self.focused = False - try: - input_manager.release_text_input(self) - except Exception: - pass - - def _changed(self, value: str) -> None: - self.text = value - self.caret = max(0, min(self.caret, len(self.text))) - if self.on_change is not None: - self.on_change(self.text) - - def update(self, input_manager, mouse_x: float, mouse_y: float) -> bool: - super().update(input_manager, mouse_x, mouse_y) - if self.enabled and self.hovered and input_manager.mouse_pressed("left"): - self.focus(input_manager) - self.caret = len(self.text) - return True - if not self.focused: - return False - for value in input_manager.text_input: - self._changed(self.text[: self.caret] + str(value) + self.text[self.caret :]) - self.caret += len(str(value)) - if input_manager.key_pressed("backspace") and self.caret > 0: - self._changed(self.text[: self.caret - 1] + self.text[self.caret :]) - self.caret -= 1 - if input_manager.key_pressed("delete") and self.caret < len(self.text): - self._changed(self.text[: self.caret] + self.text[self.caret + 1 :]) - if input_manager.key_pressed("left"): - self.caret = max(0, self.caret - 1) - if input_manager.key_pressed("right"): - self.caret = min(len(self.text), self.caret + 1) - if input_manager.key_pressed("home"): - self.caret = 0 - if input_manager.key_pressed("end"): - self.caret = len(self.text) - if input_manager.key_pressed("return") and self.on_submit is not None: - self.on_submit(self.text) - return False - - def render(self, renderer, viewport, theme: UITheme) -> None: - if not self.visible: - return - draw_rect(renderer, self.rect, theme.input_focus if self.focused else theme.input, viewport, radius=4.0) - draw_outline(renderer, self.rect, theme.accent if self.focused else theme.border, viewport, width=2.0 if self.focused else 1.0) - value = self.text or self.placeholder - color = theme.text if self.text else theme.muted - draw_text(renderer, value, self.rect.x + 9.0, self.rect.y + 7.0, viewport, scale=self.text_scale, color=color) - if self.focused: - prefix = self.text[: self.caret] - prefix_width, _ = renderer.text_measure(prefix, scale=self.text_scale * UI_SCALE) - width, height = viewport - x, y = _world_point(self.rect.x + 9.0 + prefix_width, self.rect.y + 6.0, width, height) - renderer.line(x, y, x, y + self.rect.height - 12.0, width=1.0, color=rgba(theme.text)) - - -class CheckBox(Control): - def __init__(self, rect: Rect, text: str, checked: bool = False, on_change: Callable[[bool], None] | None = None) -> None: - super().__init__(rect) - self.text = str(text) - self.checked = bool(checked) - self.on_change = on_change - - def update(self, input_manager, mouse_x: float, mouse_y: float) -> bool: - super().update(input_manager, mouse_x, mouse_y) - if self.enabled and self.hovered and input_manager.mouse_pressed("left"): - self.checked = not self.checked - if self.on_change is not None: - self.on_change(self.checked) - return True - return False - - def render(self, renderer, viewport, theme: UITheme) -> None: - if not self.visible: - return - box = Rect(self.rect.x, self.rect.y + 2.0, 20.0, 20.0) - draw_rect(renderer, box, theme.accent if self.checked else theme.input, viewport, radius=3.0) - draw_outline(renderer, box, theme.border, viewport) - if self.checked: - draw_text(renderer, "✓", box.x + 3.0, box.y - 1.0, viewport, scale=0.65, color=theme.text) - draw_text(renderer, self.text, self.rect.x + 28.0, self.rect.y + 2.0, viewport, scale=0.60, color=theme.text) - - -class Dropdown(Control): - def __init__(self, rect: Rect, options: tuple[str, ...] | list[str], selected: int = 0, on_change: Callable[[int, str], None] | None = None) -> None: - super().__init__(rect) - self.options = tuple(str(option) for option in options) - self.selected = max(0, min(int(selected), len(self.options) - 1)) if self.options else -1 - self.on_change = on_change - - @property - def value(self) -> str: - return self.options[self.selected] if 0 <= self.selected < len(self.options) else "" - - def update(self, input_manager, mouse_x: float, mouse_y: float) -> bool: - super().update(input_manager, mouse_x, mouse_y) - if self.enabled and self.hovered and input_manager.mouse_pressed("left") and self.options: - self.selected = (self.selected + 1) % len(self.options) - if self.on_change is not None: - self.on_change(self.selected, self.value) - return True - return False - - def render(self, renderer, viewport, theme: UITheme) -> None: - if not self.visible: - return - draw_rect(renderer, self.rect, theme.input, viewport, radius=4.0) - draw_outline(renderer, self.rect, theme.accent if self.hovered else theme.border, viewport) - draw_text(renderer, self.value, self.rect.x + 9.0, self.rect.y + 7.0, viewport, scale=0.60, color=theme.text) - draw_text(renderer, "▾", self.rect.x + self.rect.width - 20.0, self.rect.y + 7.0, viewport, scale=0.60, color=theme.muted) - - -class ListBox(Control): - def __init__(self, rect: Rect, on_change: Callable[[int], None] | None = None) -> None: - super().__init__(rect) - self.items: list[str] = [] - self.selected = -1 - self.on_change = on_change - self.row_height = 30.0 - self.scroll = 0 - self.row_action: Callable[[int], None] | None = None - self.row_action_state: Callable[[int], bool] | None = None - self.row_action_width = 34.0 - self.scrollbar_width = 12.0 - - def set_items(self, items: list[str] | tuple[str, ...]) -> None: - self.items = [str(item) for item in items] - self.selected = min(self.selected, len(self.items) - 1) - visible_rows = max(1, int(self.rect.height // self.row_height)) - self.scroll = min(self.scroll, max(0, len(self.items) - visible_rows)) - - def update(self, input_manager, mouse_x: float, mouse_y: float) -> bool: - super().update(input_manager, mouse_x, mouse_y) - if not self.hovered: - return False - visible_rows = max(1, int(self.rect.height // self.row_height)) - max_scroll = max(0, len(self.items) - visible_rows) - _wheel_x, wheel_y = input_manager.wheel - if wheel_y: - self.scroll = max(0, min(max_scroll, self.scroll - int(wheel_y))) - return True - if input_manager.mouse_pressed("left"): - if max_scroll and mouse_x >= self.rect.x + self.rect.width - self.scrollbar_width: - fraction = (mouse_y - self.rect.y) / max(1.0, self.rect.height) - self.scroll = max(0, min(max_scroll, int(fraction * max_scroll))) - return True - index = int((mouse_y - self.rect.y) // self.row_height) + self.scroll - if 0 <= index < len(self.items): - if self.row_action is not None and mouse_x >= self.rect.x + self.rect.width - self.row_action_width: - self.row_action(index) - return True - self.selected = index - if self.on_change is not None: - self.on_change(index) - return True - return False - - def render(self, renderer, viewport, theme: UITheme) -> None: - if not self.visible: - return - draw_rect(renderer, self.rect, theme.panel_dark, viewport) - draw_outline(renderer, self.rect, theme.border, viewport) - visible_rows = max(1, int(self.rect.height // self.row_height)) - max_scroll = max(0, len(self.items) - visible_rows) - for row in range(visible_rows): - index = row + self.scroll - if index >= len(self.items): - break - row_rect = Rect(self.rect.x + 3.0, self.rect.y + row * self.row_height + 3.0, self.rect.width - 6.0, self.row_height - 3.0) - if index == self.selected: - draw_rect(renderer, row_rect, theme.selected, viewport, radius=3.0) - text_width = row_rect.width - self.scrollbar_width - (self.row_action_width if self.row_action is not None else 0.0) - draw_text(renderer, self.items[index], row_rect.x + 8.0, row_rect.y + 7.0, viewport, scale=0.58, color=theme.text) - if self.row_action is not None: - symbol = "◉" if self.row_action_state is None or self.row_action_state(index) else "○" - draw_text(renderer, symbol, row_rect.x + text_width + self.row_action_width / 2.0, row_rect.y + 5.0, viewport, scale=0.70, color=theme.accent if symbol == "◉" else theme.muted, align="center") - if max_scroll: - track = Rect(self.rect.x + self.rect.width - self.scrollbar_width + 2.0, self.rect.y + 3.0, self.scrollbar_width - 5.0, self.rect.height - 6.0) - draw_rect(renderer, track, theme.input, viewport, radius=3.0) - thumb_height = max(22.0, track.height * visible_rows / max(visible_rows, len(self.items))) - thumb_y = track.y + (track.height - thumb_height) * (self.scroll / max_scroll) - draw_rect(renderer, Rect(track.x, thumb_y, track.width, thumb_height), theme.accent, viewport, radius=3.0) - - -class CategorizedListBox(Control): - """Scrollable list with collapsible category headers.""" - - def __init__(self, rect: Rect, on_change: Callable[[int], None] | None = None) -> None: - super().__init__(rect) - self.groups: list[tuple[str, list[tuple[str, int]]]] = [] - self.rows: list[tuple[str, str, int | None]] = [] - self.collapsed: set[str] = set() - self.selected_asset = -1 - self.on_change = on_change - self.row_height = 30.0 - self.scroll = 0 - self.scrollbar_width = 12.0 - - def set_groups(self, groups: dict[str, list[tuple[str, int]]]) -> None: - self.collapsed.intersection_update(groups) - self.groups = [(name, list(groups[name])) for name in groups] - self._rebuild_rows() - if self.selected_asset not in { - asset_index - for _category, items in self.groups - for _label, asset_index in items - }: - self.selected_asset = -1 - - def _rebuild_rows(self) -> None: - self.rows = [] - for category, items in self.groups: - self.rows.append(("category", category, None)) - if category not in self.collapsed: - self.rows.extend(("item", label, asset_index) for label, asset_index in items) - visible_rows = max(1, int(self.rect.height // self.row_height)) - self.scroll = min(self.scroll, max(0, len(self.rows) - visible_rows)) - - def update(self, input_manager, mouse_x: float, mouse_y: float) -> bool: - super().update(input_manager, mouse_x, mouse_y) - if not self.hovered: - return False - visible_rows = max(1, int(self.rect.height // self.row_height)) - max_scroll = max(0, len(self.rows) - visible_rows) - _wheel_x, wheel_y = input_manager.wheel - if wheel_y: - self.scroll = max(0, min(max_scroll, self.scroll - int(wheel_y))) - return True - if not input_manager.mouse_pressed("left"): - return False - if max_scroll and mouse_x >= self.rect.x + self.rect.width - self.scrollbar_width: - fraction = (mouse_y - self.rect.y) / max(1.0, self.rect.height) - self.scroll = max(0, min(max_scroll, int(fraction * max_scroll))) - return True - row_index = int((mouse_y - self.rect.y) // self.row_height) + self.scroll - if not 0 <= row_index < len(self.rows): - return True - row_type, label, asset_index = self.rows[row_index] - if row_type == "category": - if label in self.collapsed: - self.collapsed.remove(label) - else: - self.collapsed.add(label) - self._rebuild_rows() - return True - if asset_index is not None: - self.selected_asset = asset_index - if self.on_change is not None: - self.on_change(asset_index) - return True - - def render(self, renderer, viewport, theme: UITheme) -> None: - if not self.visible: - return - draw_rect(renderer, self.rect, theme.panel_dark, viewport) - draw_outline(renderer, self.rect, theme.border, viewport) - visible_rows = max(1, int(self.rect.height // self.row_height)) - max_scroll = max(0, len(self.rows) - visible_rows) - category_counts = {name: len(items) for name, items in self.groups} - for row in range(visible_rows): - index = row + self.scroll - if index >= len(self.rows): - break - row_type, label, asset_index = self.rows[index] - row_rect = Rect(self.rect.x + 3.0, self.rect.y + row * self.row_height + 3.0, self.rect.width - 6.0, self.row_height - 3.0) - if row_type == "category": - draw_rect(renderer, row_rect, theme.button, viewport, radius=3.0) - symbol = "▸" if label in self.collapsed else "▾" - draw_text(renderer, f"{symbol} {label} ({category_counts.get(label, 0)})", row_rect.x + 8.0, row_rect.y + 7.0, viewport, scale=0.56, color=theme.text) - else: - if asset_index == self.selected_asset: - draw_rect(renderer, row_rect, theme.selected, viewport, radius=3.0) - draw_text(renderer, label, row_rect.x + 24.0, row_rect.y + 7.0, viewport, scale=0.54, color=theme.text) - if max_scroll: - track = Rect(self.rect.x + self.rect.width - self.scrollbar_width + 2.0, self.rect.y + 3.0, self.scrollbar_width - 5.0, self.rect.height - 6.0) - draw_rect(renderer, track, theme.input, viewport, radius=3.0) - thumb_height = max(22.0, track.height * visible_rows / max(visible_rows, len(self.rows))) - thumb_y = track.y + (track.height - thumb_height) * (self.scroll / max_scroll) - draw_rect(renderer, Rect(track.x, thumb_y, track.width, thumb_height), theme.accent, viewport, radius=3.0) +from .ui import * # noqa: F401,F403 +from .ui import __all__ diff --git a/nexora/editor/tilemap_app.py b/nexora/editor/tilemap_app.py index 0e7993b..33c281e 100644 --- a/nexora/editor/tilemap_app.py +++ b/nexora/editor/tilemap_app.py @@ -1,1497 +1,14 @@ -from __future__ import annotations +"""Backward-compatible facade for the standalone TileMap editor. -from pathlib import Path +The active ``--tilemapedit`` command uses +``nexora.editor.standalone_tilemap_editor``. The former node-based editor is +no longer loaded; these aliases keep the old module import path working. +""" -from nexora import Game -from nexora.editor.commands import ( - CommandStack, - TileMapLayerAddCommand, - TileMapLayerPropertyCommand, - TileMapLayerRemoveCommand, - TileMapLayerRenameCommand, - TileMapPaintCommand, - TileMapProjectionCommand, +from .standalone_tilemap_editor import ( + StandaloneTileMapEditorApp as TileMapEditorApp, + StandaloneTileMapEditorScene as TileMapEditorScene, + run_standalone_tilemap_editor as run_tilemap_editor, ) -from nexora.editor.model import EditorDocument, SelectionService -from nexora.editor.tilemap import TileMapEditorModel -from nexora.editor.tile_palette import TilePalette -from nexora.editor.theme import ( - ACCENT, - BUTTON_BACKGROUND, - BUTTON_HOVER, - BUTTON_PRESSED, - EDITOR_BACKGROUND, - PANEL_BACKGROUND, - PANEL_BACKGROUND_DARK, - PANEL_BORDER, - TOOLBAR_BACKGROUND, - VIEWPORT_BACKGROUND, -) -from nexora.editor.viewport import EditorViewportCanvas, EditorViewportState -from nexora.nodes import ( - Button, - CheckBox, - ConfirmDialog, - Dialog, - Dropdown, - FileDialog, - Label, - ListView, - Panel, - TextInput, -) -from nexora.nodes.world.tilemap_node import TileMapNode -from nexora.scene import Scene -from nexora.tilemap import TILEMAP_ASSET_SUFFIX, TileMap, TileMapAsset, TileProjection, TileSet -from nexora.tilemap.tile_layer import LAYER_ROLES, normalize_layer_role - -from nexora.editor.app import resolve_project_path - - -class TileMapEditorScene(Scene): - """Standalone editor for authoring portable ``.tilemap.net`` assets.""" - - TOOLBAR_HEIGHT = 48.0 - STATUS_HEIGHT = 26.0 - LEFT_WIDTH = 220.0 - # The inspector contains two-column settings and therefore needs the - # same minimum width as the regular editor inspector. Keeping this - # width fixed also prevents controls from crossing into the viewport at - # the default 1440px window size. - RIGHT_WIDTH = 310.0 - BOTTOM_HEIGHT = 210.0 - GAP = 1.0 - LAYER_ROLE_LABELS = tuple(role.title() for role in LAYER_ROLES) - - def __init__( - self, - game, - project_path: Path, - *, - document: EditorDocument, - selection: SelectionService, - ) -> None: - super().__init__("NexoraTileMapEditor") - self.game = game - self.project_path = Path(project_path).resolve() - self.document = document - self.selection = selection - self.commands = CommandStack() - self.viewport_state = EditorViewportState() - self.tilemap_tool = "paint" - self._tilemap_model = None - self._tilemap_drag_cells = None - self._tilemap_hover_cell = None - self._last_size = (-1.0, -1.0) - self._pending_action = None - self._pending_tileset_path: Path | None = None - self._tileset_setup_image_size: tuple[int, int] | None = None - self._build_ui() - - # ---------------------------------------------------------- - # UI construction - # ---------------------------------------------------------- - - def _build_ui(self) -> None: - root = self.ui - self.background = root.create_child("EditorBackground", node_type=Panel) - self.background.background = EDITOR_BACKGROUND - self.toolbar = root.create_child("Toolbar", node_type=Panel) - self.toolbar.background = TOOLBAR_BACKGROUND - self.toolbar.border_color = PANEL_BORDER - self.toolbar.border_width = 1.0 - self.map_panel = root.create_child("MapPanel", node_type=Panel) - self._style_panel(self.map_panel) - self.viewport = root.create_child("Viewport", node_type=Panel) - self.viewport.background = VIEWPORT_BACKGROUND - self.viewport.border_color = PANEL_BORDER - self.viewport.border_width = 1.0 - self.info_panel = root.create_child("InfoPanel", node_type=Panel) - self._style_panel(self.info_panel) - self.bottom_dock = root.create_child("BottomDock", node_type=Panel) - self.bottom_dock.background = PANEL_BACKGROUND_DARK - self.bottom_dock.border_color = PANEL_BORDER - self.bottom_dock.border_width = 1.0 - self.status_bar = root.create_child("StatusBar", node_type=Panel) - self.status_bar.background = TOOLBAR_BACKGROUND - self.status_bar.border_color = PANEL_BORDER - self.status_bar.border_width = 1.0 - - self._build_toolbar() - self._build_map_panel() - self._build_viewport() - self._build_info_panel() - self._build_bottom_dock() - self._build_dialogs() - self._build_status_bar() - self.selection.changed.connect(self._on_selection_changed) - self._refresh_document_ui() - - def _style_panel(self, panel: Panel) -> None: - panel.background = PANEL_BACKGROUND - panel.border_color = PANEL_BORDER - panel.border_width = 1.0 - - def _button(self, parent: Panel, name: str, text: str, callback) -> Button: - button = parent.create_child(name, node_type=Button) - button.text = text - button.text_scale = 0.68 - button.normal_background = BUTTON_BACKGROUND - button.hover_background = BUTTON_HOVER - button.pressed_background = BUTTON_PRESSED - button.focus_background = BUTTON_HOVER - button.focus_border_color = ACCENT - button.on_click = callback - return button - - def _label(self, parent: Panel, name: str, text: str, scale: float = 0.62) -> Label: - label = parent.create_child(name, node_type=Label) - label.text = text - label.scale = scale - label.anchor = (0.0, 0.0) - label.pivot = (0.0, 0.0) - return label - - def _build_toolbar(self) -> None: - self.brand_label = self._label(self.toolbar, "Brand", "NEXORA", 0.9) - self.new_button = self._button(self.toolbar, "NewButton", "New", self.request_new_map) - self.open_button = self._button(self.toolbar, "OpenButton", "Open", self.request_open_map) - self.save_button = self._button(self.toolbar, "SaveButton", "Save", self.save_map) - self.save_as_button = self._button(self.toolbar, "SaveAsButton", "Save As", self.save_map_as) - self.tileset_button = self._button(self.toolbar, "TilesetButton", "Tileset", self.open_tileset_browser) - self.scene_editor_button = self._button( - self.toolbar, "SceneEditorButton", "Scene Editor", self._open_scene_editor - ) - self.undo_button = self._button(self.toolbar, "UndoButton", "Undo", self.undo) - self.redo_button = self._button(self.toolbar, "RedoButton", "Redo", self.redo) - self.document_label = self._label(self.toolbar, "DocumentLabel", "", 0.68) - self.document_label.anchor = (1.0, 0.5) - self.document_label.pivot = (1.0, 0.5) - - def _build_map_panel(self) -> None: - self.map_title = self._label(self.map_panel, "MapTitle", "Map Layers", 0.78) - self.layer_list = self.map_panel.create_child("Layers", node_type=ListView) - self.layer_list.item_height = 30.0 - self.layer_list.spacing = 1.0 - self.layer_list.padding_left = 5.0 - self.layer_list.padding_right = 5.0 - self.layer_list.padding_top = 5.0 - self.layer_list.padding_bottom = 5.0 - self.layer_list.text_padding_left = 7.0 - self.layer_list.text_scale = 0.62 - self.layer_list.on_change = self._on_layer_selected - self.layer_hint = self._label( - self.map_panel, - "LayerHint", - "Paint on active layer.", - 0.52, - ) - self.add_layer_button = self._button(self.map_panel, "AddLayerButton", "+ Layer", self.add_layer) - self.remove_layer_button = self._button(self.map_panel, "RemoveLayerButton", "- Layer", self.remove_layer) - - def _build_viewport(self) -> None: - self.viewport_canvas = self.viewport.create_child( - "ViewportCanvas", node_type=EditorViewportCanvas - ) - self.viewport_canvas.editor_scene = self - self.viewport_title = self._label(self.viewport, "ViewportTitle", "2D TileMap View", 0.78) - self.viewport_subtitle = self._label(self.viewport, "ViewportSubtitle", "", 0.58) - self.viewport_controls = self._label( - self.viewport, - "ViewportControls", - "P Paint X Erase B Fill MMB Pan Wheel Zoom", - 0.54, - ) - self.tool_label = self._label(self.viewport, "ToolLabel", "", 0.56) - self.select_button = self._button(self.viewport, "SelectButton", "Select", lambda: self._set_tilemap_tool("select")) - self.paint_button = self._button(self.viewport, "PaintButton", "Paint", lambda: self._set_tilemap_tool("paint")) - self.erase_button = self._button(self.viewport, "EraseButton", "Erase", lambda: self._set_tilemap_tool("erase")) - self.fill_button = self._button(self.viewport, "FillButton", "Fill", lambda: self._set_tilemap_tool("fill")) - self.projection_button = self._button( - self.viewport, "ProjectionButton", "Angled 2D", self._set_angled_2d_projection - ) - self.tile_input = self.viewport.create_child("TileInput", node_type=TextInput) - self.tile_input.placeholder = "Tile ID" - self.tile_input.set_text("0", emit=False) - self.tile_input.auto_size = False - - def _build_info_panel(self) -> None: - self.info_title = self._label(self.info_panel, "InfoTitle", "TileMap", 0.78) - self.info_label = self._label(self.info_panel, "Info", "", 0.58) - self.layer_inspector_title = self._label(self.info_panel, "LayerInspectorTitle", "Selected Layer", 0.64) - self.layer_name_label = self._label(self.info_panel, "LayerNameLabel", "Name", 0.48) - self.layer_name_input = self.info_panel.create_child("LayerName", node_type=TextInput) - self.layer_name_input.auto_size = False - self.layer_name_input.text_scale = 0.62 - self.layer_name_input.on_submit = self._on_layer_name_submitted - self.layer_role_label = self._label(self.info_panel, "LayerRoleLabel", "Role", 0.48) - self.layer_role_dropdown = self.info_panel.create_child("LayerRole", node_type=Dropdown) - self.layer_role_dropdown.set_options(self.LAYER_ROLE_LABELS) - self.layer_role_dropdown.on_change = self._on_layer_role_changed - self.layer_visible_checkbox = self.info_panel.create_child("LayerVisible", node_type=CheckBox) - self.layer_visible_checkbox.text = "Visible" - self.layer_visible_checkbox.text_scale = 0.58 - self.layer_visible_checkbox.on_change = lambda value: self._set_layer_property("visible", value) - self.layer_enabled_checkbox = self.info_panel.create_child("LayerEnabled", node_type=CheckBox) - self.layer_enabled_checkbox.text = "Enabled" - self.layer_enabled_checkbox.text_scale = 0.58 - self.layer_enabled_checkbox.on_change = lambda value: self._set_layer_property("enabled", value) - self.layer_render_label = self._label(self.info_panel, "LayerRenderLabel", "Render Layer", 0.48) - self.layer_render_input = self.info_panel.create_child("LayerRender", node_type=TextInput) - self.layer_render_input.auto_size = False - self.layer_render_input.text_scale = 0.62 - self.layer_render_input.on_submit = self._on_layer_render_submitted - self.layer_opacity_label = self._label(self.info_panel, "LayerOpacityLabel", "Opacity", 0.48) - self.layer_opacity_input = self.info_panel.create_child("LayerOpacity", node_type=TextInput) - self.layer_opacity_input.auto_size = False - self.layer_opacity_input.text_scale = 0.62 - self.layer_opacity_input.on_submit = self._on_layer_opacity_submitted - self.layer_y_sort_checkbox = self.info_panel.create_child("LayerYSort", node_type=CheckBox) - self.layer_y_sort_checkbox.text = "Y-Sort" - self.layer_y_sort_checkbox.text_scale = 0.58 - self.layer_y_sort_checkbox.on_change = lambda value: self._set_layer_property("y_sort", value) - self.layer_role_hint = self._label( - self.info_panel, - "LayerRoleHint", - "Roles: Ground, Objects, Collision, Trigger ...", - 0.43, - ) - self.config_title = self._label(self.info_panel, "ConfigTitle", "Tileset / Map Settings", 0.64) - self.tileset_path_label = self._label(self.info_panel, "TilesetPath", "No tileset selected", 0.48) - self.apply_tileset_button = self._button(self.info_panel, "ApplyTileset", "Apply Tileset", self.apply_tileset_settings) - self.apply_map_button = self._button(self.info_panel, "ApplyMap", "Apply Map Size", self.apply_map_settings) - self._config_labels = {} - self.map_width_input = self._config_input("MapWidth", "Map W", "16") - self.map_height_input = self._config_input("MapHeight", "Map H", "16") - self.tile_width_input = self._config_input("TileWidth", "Tile W", "32") - self.tile_height_input = self._config_input("TileHeight", "Tile H", "32") - self.gap_x_input = self._config_input("GapX", "Gap X", "0") - self.gap_y_input = self._config_input("GapY", "Gap Y", "0") - self.margin_x_input = self._config_input("MarginX", "Margin X", "0") - self.margin_y_input = self._config_input("MarginY", "Margin Y", "0") - - def _config_input(self, name: str, label: str, value: str) -> TextInput: - self._config_labels[name] = self._label(self.info_panel, f"{name}Label", label, 0.48) - input_node = self.info_panel.create_child(name, node_type=TextInput) - input_node.set_text(value, emit=False) - input_node.auto_size = False - input_node.text_scale = 0.62 - return input_node - - def _build_bottom_dock(self) -> None: - self.palette_title = self._label(self.bottom_dock, "PaletteTitle", "Tile Palette", 0.78) - self.tile_palette = self.bottom_dock.create_child("TilePalette", node_type=TilePalette) - self.tile_palette.on_change = self._on_tile_selected - self.palette_hint = self._label( - self.bottom_dock, - "PaletteHint", - "No tileset selected - click Tileset to choose an image.", - 0.55, - ) - - def _build_dialogs(self) -> None: - self.new_map_dialog = self.ui.create_child("NewMapDialog", node_type=Dialog) - self.new_map_dialog.title = "New TileMap" - self.new_map_dialog.confirm_text = "Create" - self.new_map_dialog.dialog_size = (520.0, 235.0) - self.new_map_name = self.new_map_dialog.content.create_child("MapName", node_type=TextInput) - self.new_map_name.placeholder = "Map name" - self.new_map_name.size = (450.0, 44.0) - self.new_map_name.anchor = (0.5, 0.5) - self.new_map_name.pivot = (0.5, 0.5) - self.new_map_name.on_submit = lambda _text: self.new_map_dialog.confirm() - self.new_map_dialog.on_confirm = self._create_new_map - - self.open_dialog = self.ui.create_child("OpenMapDialog", node_type=FileDialog) - self.open_dialog.file_selected.connect(self._on_open_map_selected) - self.save_dialog = self.ui.create_child("SaveMapDialog", node_type=FileDialog) - self.save_dialog.file_selected.connect(self._on_save_map_selected) - self.tileset_dialog = self.ui.create_child("TilesetDialog", node_type=FileDialog) - self.tileset_dialog.file_selected.connect(self._on_tileset_selected) - - self.tileset_setup_dialog = self.ui.create_child("TilesetSetupDialog", node_type=Dialog) - self.tileset_setup_dialog.dialog_size = (680.0, 430.0) - self.tileset_setup_dialog.title = "Tileset Setup" - self.tileset_setup_dialog.confirm_text = "Apply Tileset" - self.tileset_setup_dialog.cancel_text = "Cancel" - self.tileset_setup_dialog.close_on_confirm = False - self.tileset_setup_dialog.on_confirm = self._confirm_tileset_setup - self.tileset_setup_info = self.tileset_setup_dialog.content.create_child( - "TilesetSetupInfo", node_type=Label - ) - self.tileset_setup_info.scale = 0.62 - self.tileset_setup_error = self.tileset_setup_dialog.content.create_child( - "TilesetSetupError", node_type=Label - ) - self.tileset_setup_error.scale = 0.52 - self.tileset_setup_error.text = "" - self.tileset_setup_summary = self.tileset_setup_dialog.content.create_child( - "TilesetSetupSummary", node_type=Label - ) - self.tileset_setup_summary.scale = 0.52 - self.tileset_setup_inputs: dict[str, TextInput] = {} - for name, label, fallback in ( - ("tile_width", "Tile Width", "32"), - ("tile_height", "Tile Height", "32"), - ("gap_x", "Gap X", "0"), - ("gap_y", "Gap Y", "0"), - ("margin_x", "Margin X", "0"), - ("margin_y", "Margin Y", "0"), - ): - label_node = self.tileset_setup_dialog.content.create_child( - f"TilesetSetup{label.replace(' ', '')}Label", node_type=Label - ) - label_node.text = label - label_node.scale = 0.48 - input_node = self.tileset_setup_dialog.content.create_child( - f"TilesetSetup{label.replace(' ', '')}", node_type=TextInput - ) - input_node.auto_size = False - input_node.text_scale = 0.62 - input_node.set_text(fallback, emit=False) - input_node.on_change = lambda _value: self._update_tileset_setup_summary() - self.tileset_setup_inputs[name] = input_node - setattr(self, f"tileset_setup_{name}_label", label_node) - - self._sync_tileset_setup_layout() - - self.unsaved_dialog = self.ui.create_child("UnsavedDialog", node_type=ConfirmDialog) - self.unsaved_dialog.title = "Unsaved Changes" - self.unsaved_dialog.confirm_text = "Discard" - self.unsaved_dialog.cancel_text = "Cancel" - self.unsaved_dialog.message = "The current map has unsaved changes.\nDiscard them and continue?" - self.unsaved_dialog.on_confirm = self._continue_pending_action - self.unsaved_dialog.on_cancel = self._clear_pending_action - - self.error_dialog = self.ui.create_child("ErrorDialog", node_type=ConfirmDialog) - self.error_dialog.title = "TileMap Editor Error" - self.error_dialog.confirm_text = "OK" - self.error_dialog.show_cancel_button = False - - def _build_status_bar(self) -> None: - self.status_label = self._label(self.status_bar, "Status", "Ready", 0.60) - self.status_label.anchor = (0.0, 0.5) - self.status_label.pivot = (0.0, 0.5) - self.version_label = self._label(self.status_bar, "Version", "Nexora TileMap Editor v0.10", 0.60) - self.version_label.anchor = (1.0, 0.5) - self.version_label.pivot = (1.0, 0.5) - - # ---------------------------------------------------------- - # Document workflow - # ---------------------------------------------------------- - - def _serialization_context(self) -> dict: - context = { - "game": self.game, - "engine": self.game.engine, - "renderer": self.game.renderer, - "input": self.game.input, - "scene": self.document.scene, - } - if self.game.engine is not None: - context["assets"] = self.game.engine.assets - context["audio"] = self.game.engine.audio - return context - - def _asset_root(self) -> Path: - assets = self.project_path / "assets" - return assets if assets.is_dir() else self.project_path - - def _asset_start_directory(self) -> Path: - root = self._asset_root() - tilemaps = root / "tilemaps" - return tilemaps if tilemaps.is_dir() else root - - def _run_after_dirty_check(self, action) -> None: - if self.document.dirty: - self._pending_action = action - self.unsaved_dialog.open() - else: - action() - - def _continue_pending_action(self) -> None: - action = self._pending_action - self._pending_action = None - if action is not None: - action() - - def _clear_pending_action(self) -> None: - self._pending_action = None - - def request_new_map(self) -> None: - self._run_after_dirty_check(self._open_new_map_dialog) - - def _open_new_map_dialog(self) -> None: - self.new_map_name.set_text("Untitled", emit=False) - self.new_map_dialog.open(focus=self.new_map_name) - - def _create_new_map(self) -> None: - name = self.new_map_name.text.strip() or "Untitled" - scene = self.document.new(name) - node = self._create_blank_tilemap(scene) - self.document.path = None - self._pending_tileset_path = None - self.commands.clear() - self._set_tilemap_node(node) - self.status_label.text = f"Created TileMap {name}" - - def _create_blank_tilemap(self, scene: Scene) -> TileMapNode: - node = TileMapNode("TileMap", scene.world) - width, height = self._read_config_pair(self.map_width_input, self.map_height_input, (16, 16)) - tile_width, tile_height = self._read_config_pair(self.tile_width_input, self.tile_height_input, (32, 32)) - tilemap = TileMap( - width=width, - height=height, - tile_width=tile_width, - tile_height=tile_height, - name=scene.name, - projection=TileProjection.ANGLED_2D, - ) - tilemap.create_layer("ground") - tileset = TileSet( - columns=1, - rows=1, - tile_width=tile_width, - tile_height=tile_height, - name="DefaultTileSet", - spacing_x=self._read_int(self.gap_x_input, 0), - spacing_y=self._read_int(self.gap_y_input, 0), - margin_x=self._read_int(self.margin_x_input, 0), - margin_y=self._read_int(self.margin_y_input, 0), - ) - node.set_map(tilemap, tileset, None) - scene.root.add_child(node) - return node - - def request_open_map(self) -> None: - self._run_after_dirty_check(self._open_map_file_dialog) - - def _open_map_file_dialog(self) -> None: - self.open_dialog.configure( - mode="open", - root_path=self._asset_root(), - current_path=self._asset_start_directory(), - extensions=(TILEMAP_ASSET_SUFFIX,), - title="Open TileMap Asset", - ) - self.open_dialog.open() - - def open_path(self, path: str | Path) -> None: - resolved = Path(path).expanduser() - if not resolved.is_absolute(): - candidate = self.project_path / resolved - resolved = candidate if candidate.exists() else self._asset_root() / resolved - self._load_map(resolved.resolve()) - - def _on_open_map_selected(self, _dialog, path: Path) -> None: - self._load_map(path) - - def _load_map(self, path: Path) -> None: - try: - asset = TileMapAsset.load(path) - tilemap, tileset = asset.build() - scene = self.document.new(asset.name) - node = TileMapNode("TileMap", scene.world) - texture = None - if tileset.texture_asset and self.game.engine is not None: - texture = self.game.engine.assets.texture(tileset.texture_asset) - node.set_map(tilemap, tileset, texture) - node.tilemap_asset = self._asset_relative(path) - self._pending_tileset_path = ( - self._asset_root() / tileset.texture_asset - if tileset.texture_asset - else None - ) - scene.root.add_child(node) - self.document.path = path.resolve() - self.document.mark_clean() - except Exception as exc: - self._show_error(f"Could not open TileMap asset:\n{exc}") - return - self.commands.clear() - self._set_tilemap_node(node) - self.status_label.text = f"Opened {path.name}" - - def save_map(self) -> None: - if self.document.path is None: - self.save_map_as() - return - try: - path = self._save_asset(self.document.path) - except Exception as exc: - self._show_error(f"Could not save TileMap asset:\n{exc}") - return - self._refresh_document_ui() - self.status_label.text = f"Saved {path.name}" - - def save_map_as(self) -> None: - self.save_dialog.configure( - mode="save", - root_path=self._asset_root(), - current_path=self._asset_start_directory(), - extensions=(TILEMAP_ASSET_SUFFIX,), - title="Save TileMap Asset As", - ) - base_name = self.document.display_name - if self.document.path is not None and self.document.path.name.lower().endswith(TILEMAP_ASSET_SUFFIX): - base_name = self.document.path.name[: -len(TILEMAP_ASSET_SUFFIX)] - self.save_dialog.filename_input.set_text(f"{base_name}{TILEMAP_ASSET_SUFFIX}", emit=False) - self.save_dialog.open(focus=self.save_dialog.filename_input) - - def _on_save_map_selected(self, _dialog, path: Path) -> None: - try: - saved = self._save_asset(path) - except Exception as exc: - self._show_error(f"Could not save TileMap asset:\n{exc}") - return - self._refresh_document_ui() - self.status_label.text = f"Saved {saved.name}" - - def _read_int(self, input_node: TextInput, default: int) -> int: - try: - return max(0, int(input_node.text.strip())) - except (TypeError, ValueError): - return int(default) - - def _read_config_pair(self, first: TextInput, second: TextInput, default: tuple[int, int]) -> tuple[int, int]: - return max(1, self._read_int(first, default[0])), max(1, self._read_int(second, default[1])) - - def _asset_relative(self, path: Path) -> str: - try: - return path.resolve().relative_to(self._asset_root().resolve()).as_posix() - except ValueError: - return path.name - - def _save_asset(self, path: Path) -> Path: - model = self._selected_tilemap_editor_model() - if model is None or model.node.tilemap is None or model.node.tileset is None: - raise RuntimeError("No TileMap is ready to save.") - if not model.tileset.texture_asset: - raise RuntimeError("Select a tileset image before saving the TileMap asset.") - saved = TileMapAsset.from_components(model.tilemap, model.tileset, name=model.tilemap.name).save(path) - self.document.path = saved - self.document.mark_clean() - model.node.tilemap_asset = self._asset_relative(saved) - return saved - - def open_tileset_browser(self) -> None: - self.tileset_dialog.configure( - mode="open", - root_path=self._asset_root(), - current_path=self._asset_root(), - extensions=(".png", ".jpg", ".jpeg", ".bmp", ".webp"), - title="Choose Tileset Image", - activate_on_single_click=True, - ) - self.tileset_dialog.open() - - def _on_tileset_selected(self, _dialog, path: Path) -> None: - self._pending_tileset_path = path.resolve() - self.tileset_path_label.text = self._asset_relative(path) - try: - image = self.game.engine.assets.image(self._pending_tileset_path) - except Exception as exc: - self._show_error(f"Could not read tileset image:\n{exc}") - return - - self._tileset_setup_image_size = (int(image.width), int(image.height)) - self.tileset_setup_info.text = ( - f"Image: {image.width} x {image.height} px\n" - f"Asset: {self._asset_relative(path)}" - ) - self.tileset_setup_error.text = ( - "Set the grid values, then apply the tileset." - ) - for name, source in ( - ("tile_width", self.tile_width_input), - ("tile_height", self.tile_height_input), - ("gap_x", self.gap_x_input), - ("gap_y", self.gap_y_input), - ("margin_x", self.margin_x_input), - ("margin_y", self.margin_y_input), - ): - self.tileset_setup_inputs[name].set_text(source.text, emit=False) - self._update_tileset_setup_summary() - self.tileset_setup_dialog.open(focus=self.tileset_setup_inputs["tile_width"]) - self.status_label.text = "Tileset selected. Configure the tile grid." - - def _setup_int(self, name: str, default: int = 0) -> int: - input_node = self.tileset_setup_inputs[name] - try: - return int(input_node.text.strip()) - except (TypeError, ValueError): - return int(default) - - def _update_tileset_setup_summary(self) -> None: - if self._tileset_setup_image_size is None: - self.tileset_setup_summary.text = "" - return - image_width, image_height = self._tileset_setup_image_size - tile_width = self._setup_int("tile_width", 32) - tile_height = self._setup_int("tile_height", 32) - gap_x = self._setup_int("gap_x", 0) - gap_y = self._setup_int("gap_y", 0) - margin_x = self._setup_int("margin_x", 0) - margin_y = self._setup_int("margin_y", 0) - if tile_width <= 0 or tile_height <= 0 or gap_x < 0 or gap_y < 0: - self.tileset_setup_summary.text = "Grid values are not valid yet." - return - usable_width = image_width - margin_x * 2 - usable_height = image_height - margin_y * 2 - columns = (usable_width + gap_x) // (tile_width + gap_x) if usable_width > 0 else 0 - rows = (usable_height + gap_y) // (tile_height + gap_y) if usable_height > 0 else 0 - fits = ( - columns > 0 - and rows > 0 - and columns * tile_width + max(0, columns - 1) * gap_x == usable_width - and rows * tile_height + max(0, rows - 1) * gap_y == usable_height - ) - self.tileset_setup_summary.text = ( - f"Detected grid: {columns} x {rows} tiles" - if fits - else "Grid does not fit the image exactly yet." - ) - - def _confirm_tileset_setup(self) -> None: - for name, target in ( - ("tile_width", self.tile_width_input), - ("tile_height", self.tile_height_input), - ("gap_x", self.gap_x_input), - ("gap_y", self.gap_y_input), - ("margin_x", self.margin_x_input), - ("margin_y", self.margin_y_input), - ): - target.set_text(self.tileset_setup_inputs[name].text, emit=False) - if self.apply_tileset_settings(show_error=False): - self.tileset_setup_dialog.close() - - - def apply_tileset_settings(self, *, show_error: bool = True) -> bool: - model = self._selected_tilemap_editor_model() - if model is None: - self.status_label.text = "Create or open a TileMap asset first." - return False - if self._pending_tileset_path is None: - self.status_label.text = "Choose a tileset image first." - return False - try: - texture_asset = self._asset_relative(self._pending_tileset_path) - image = self.game.engine.assets.image(self._pending_tileset_path) - tile_width, tile_height = self._read_config_pair(self.tile_width_input, self.tile_height_input, (32, 32)) - gap_x = self._read_int(self.gap_x_input, 0) - gap_y = self._read_int(self.gap_y_input, 0) - margin_x = self._read_int(self.margin_x_input, 0) - margin_y = self._read_int(self.margin_y_input, 0) - usable_width = int(image.width) - margin_x * 2 - usable_height = int(image.height) - margin_y * 2 - columns = (usable_width + gap_x) // (tile_width + gap_x) - rows = (usable_height + gap_y) // (tile_height + gap_y) - if columns <= 0 or rows <= 0: - raise ValueError("Tile size and margins do not fit the selected image.") - expected_width = columns * tile_width + max(0, columns - 1) * gap_x - expected_height = rows * tile_height + max(0, rows - 1) * gap_y - if expected_width != usable_width or expected_height != usable_height: - raise ValueError("Grid does not fit image; adjust tile size, gap, or margin.") - tileset = TileSet( - name=self._pending_tileset_path.stem, - columns=columns, - rows=rows, - tile_width=tile_width, - tile_height=tile_height, - texture_asset=texture_asset, - spacing_x=gap_x, - spacing_y=gap_y, - margin_x=margin_x, - margin_y=margin_y, - ) - model.tilemap.tile_width = tile_width - model.tilemap.tile_height = tile_height - texture = self.game.engine.assets.texture(self._pending_tileset_path) - model.node.set_map(model.tilemap, tileset, texture) - model.node.tilemap_asset = None - self.document.mark_dirty() - self._refresh_document_ui() - self.status_label.text = f"Tileset applied: {columns} x {rows} tiles" - self.tileset_setup_error.text = "Tileset is ready." - return True - except Exception as exc: - message = str(exc) - if self.tileset_setup_dialog.is_open: - self.tileset_setup_error.text = message - self._update_tileset_setup_summary() - elif show_error: - self._show_error(f"Could not apply tileset:\n{message}") - else: - self.status_label.text = message - return False - - def apply_map_settings(self) -> None: - model = self._selected_tilemap_editor_model() - if model is None: - return - width, height = self._read_config_pair(self.map_width_input, self.map_height_input, model.tilemap.size) - if (width, height) == model.tilemap.size: - self.status_label.text = "Map size is unchanged" - return - old_map = model.tilemap - new_map = TileMap( - name=old_map.name, - width=width, - height=height, - tile_width=old_map.tile_width, - tile_height=old_map.tile_height, - projection=old_map.projection, - ) - for old_layer in old_map.layers: - new_layer = new_map.create_layer( - old_layer.name, - visible=old_layer.visible, - enabled=old_layer.enabled, - opacity=old_layer.opacity, - render_layer=old_layer.render_layer, - y_sort=old_layer.y_sort, - role=old_layer.role, - ) - for y in range(min(height, old_layer.height)): - for x in range(min(width, old_layer.width)): - tile_id = old_layer.get_tile(x, y) - if tile_id >= 0: - new_layer.set_tile(x, y, tile_id) - node = model.node - node.set_map(new_map, model.tileset, node.texture) - self._tilemap_model = None - self.document.mark_dirty() - self._refresh_document_ui() - self.status_label.text = f"Map resized to {width} x {height}" - - def add_layer(self) -> None: - model = self._selected_tilemap_editor_model() - if model is None: - return - index = 1 - while model.tilemap.has_layer(f"Layer {index}"): - index += 1 - name = f"Layer {index}" - self.commands.execute(TileMapLayerAddCommand(model.tilemap, name)) - model.set_active_layer(name) - self.document.mark_dirty() - self._refresh_document_ui() - self.status_label.text = f"Added layer: {name}" - - def remove_layer(self) -> None: - model = self._selected_tilemap_editor_model() - if model is None or model.tilemap.layer_count <= 1: - self.status_label.text = "A TileMap needs at least one layer." - return - name = model.active_layer_name - self.commands.execute(TileMapLayerRemoveCommand(model.tilemap, name)) - model.set_active_layer(model.tilemap.layer_names[0]) - self.document.mark_dirty() - self._refresh_document_ui() - self.status_label.text = f"Removed layer: {name}" - - def _show_error(self, message: str) -> None: - self.error_dialog.message = str(message) - self.error_dialog.open() - - def _open_scene_editor(self) -> None: - import subprocess - import sys - - command = [sys.executable, "-m", "nexora", "--editor", str(self.project_path)] - try: - subprocess.Popen(command, cwd=str(self.project_path)) - self.status_label.text = "Scene Editor gestartet" - except OSError as exc: - self._show_error(f"Could not start Scene Editor:\n{exc}") - - # ---------------------------------------------------------- - # TileMap operations - # ---------------------------------------------------------- - - @staticmethod - def _find_tilemap_node(root) -> TileMapNode | None: - if isinstance(root, TileMapNode): - return root - for child in tuple(getattr(root, "children", ())): - found = TileMapEditorScene._find_tilemap_node(child) - if found is not None: - return found - return None - - def _set_tilemap_node(self, node: TileMapNode | None) -> None: - self._tilemap_drag_cells = None - self._tilemap_hover_cell = None - self._tilemap_model = None - if node is not None: - self._tilemap_model = TileMapEditorModel(node) - self.selection.select(node) - else: - self.selection.clear() - self._refresh_document_ui() - - def _selected_tilemap_editor_model(self): - node = self.selection.selected - if not isinstance(node, TileMapNode) or node.tilemap is None or node.tileset is None: - return None - if self._tilemap_model is None or self._tilemap_model.node is not node: - self._tilemap_model = TileMapEditorModel(node) - if self._tilemap_model.active_layer_name not in node.tilemap.layer_names and node.tilemap.layer_count: - self._tilemap_model.set_active_layer(node.tilemap.layer_names[0]) - return self._tilemap_model - - def _selected_layer(self): - model = self._selected_tilemap_editor_model() - if model is None or model.active_layer_name is None: - return None - return model.active_layer - - def _set_layer_property(self, property_name: str, value) -> None: - layer = self._selected_layer() - if layer is None or getattr(layer, property_name) == value: - return - try: - self.commands.execute(TileMapLayerPropertyCommand(layer, property_name, value)) - self.document.mark_dirty() - self._refresh_document_ui() - self.status_label.text = f"Layer {property_name} updated" - except (TypeError, ValueError, AttributeError) as exc: - self._show_error(f"Could not update layer:\n{exc}") - - def _on_layer_name_submitted(self, value: str) -> None: - layer = self._selected_layer() - if layer is None: - return - new_name = str(value).strip() - if not new_name or new_name == layer.name: - self._refresh_document_ui() - return - try: - self.commands.execute(TileMapLayerRenameCommand(self._selected_tilemap_editor_model().tilemap, layer, new_name)) - self.document.mark_dirty() - self._refresh_document_ui() - self.status_label.text = f"Renamed layer to {new_name}" - except (TypeError, ValueError) as exc: - self.layer_name_input.set_text(layer.name, emit=False) - self._show_error(f"Could not rename layer:\n{exc}") - - def _on_layer_role_changed(self, _index: int, value: str | None) -> None: - if value is None: - return - role = normalize_layer_role(value) - self._set_layer_property("role", role) - - def _on_layer_render_submitted(self, value: str) -> None: - try: - render_layer = int(str(value).strip()) - except ValueError: - self._refresh_document_ui() - self.status_label.text = "Render Layer must be an integer" - return - self._set_layer_property("render_layer", render_layer) - - def _on_layer_opacity_submitted(self, value: str) -> None: - try: - opacity = max(0.0, min(1.0, float(str(value).strip()))) - except ValueError: - self._refresh_document_ui() - self.status_label.text = "Opacity must be a number from 0 to 1" - return - self._set_layer_property("opacity", opacity) - - def _set_tilemap_tool(self, tool: str) -> None: - tool = str(tool).lower() - if tool not in {"select", "paint", "erase", "fill"}: - raise ValueError(f"Unknown TileMap tool: {tool}") - self.tilemap_tool = tool - self._tilemap_drag_cells = None - self._refresh_tool_ui() - self.status_label.text = f"TileMap tool: {tool.title()}" - - def _set_angled_2d_projection(self) -> None: - model = self._selected_tilemap_editor_model() - if model is None: - self.status_label.text = "No configured TileMap selected" - return - if model.tilemap.projection is TileProjection.ANGLED_2D: - self.status_label.text = "TileMap is already Angled 2D" - return - self.commands.execute(TileMapProjectionCommand(model.node, TileProjection.ANGLED_2D)) - self.document.mark_dirty() - self._refresh_document_ui() - self.status_label.text = "TileMap projection set to Angled 2D" - - def _on_tile_selected(self, index: int, _label: str | None) -> None: - model = self._selected_tilemap_editor_model() - if model is None: - return - try: - model.set_selected_tile(index) - except (IndexError, ValueError) as exc: - self.status_label.text = str(exc) - return - self.tile_input.set_text(str(index), emit=False) - self.status_label.text = f"Selected Tile {index}" - - def _on_layer_selected(self, index: int, _label: str | None) -> None: - model = self._selected_tilemap_editor_model() - if model is None or not (0 <= index < len(model.layer_names)): - return - model.set_active_layer(model.layer_names[index]) - self._refresh_document_ui() - self.status_label.text = f"Active layer: {model.active_layer_name}" - - def _read_tile_id(self, model) -> bool: - try: - model.set_selected_tile(int(self.tile_input.text.strip())) - except (TypeError, ValueError, IndexError) as exc: - self.status_label.text = f"Invalid Tile ID: {exc}" - return False - return True - - def _apply_tilemap_command(self, command: TileMapPaintCommand) -> None: - if not command.edits: - self.status_label.text = "TileMap: no cells changed" - return - self.commands.execute(command) - self.document.mark_dirty() - self._refresh_document_ui() - self.status_label.text = command.label - - def _viewport_mouse_centered(self) -> tuple[float, float]: - mouse_x, mouse_y = self.game.input.mouse_position - return ( - float(mouse_x) - float(self.game.renderer.width) * 0.5, - float(mouse_y) - float(self.game.renderer.height) * 0.5, - ) - - def _mouse_inside_viewport_canvas(self) -> bool: - if not hasattr(self, "viewport_canvas"): - return False - return self.viewport_canvas.contains_point(*self._viewport_mouse_centered()) - - def _tilemap_cell_under_mouse(self, model): - mouse_x, mouse_y = self._viewport_mouse_centered() - center_x, center_y = self.viewport_canvas.calculate_position() - world_x, world_y = self.viewport_state.screen_to_world(mouse_x, mouse_y, center_x, center_y) - return model.cell_from_world(world_x, world_y) - - def _update_tilemap_input(self) -> None: - if self._any_dialog_open(): - self._tilemap_drag_cells = None - self._tilemap_hover_cell = None - return - model = self._selected_tilemap_editor_model() - if model is None or not model.layer_names: - self._tilemap_drag_cells = None - self._tilemap_hover_cell = None - return - input_manager = self.game.input - if input_manager.key_pressed("p"): - self._set_tilemap_tool("paint") - elif input_manager.key_pressed("x"): - self._set_tilemap_tool("erase") - elif input_manager.key_pressed("b"): - self._set_tilemap_tool("fill") - self._tilemap_hover_cell = self._tilemap_cell_under_mouse(model) if self._mouse_inside_viewport_canvas() else None - if input_manager.key_pressed("escape"): - self._tilemap_drag_cells = None - return - if self.tilemap_tool == "select": - return - if self._tilemap_drag_cells is not None: - if input_manager.mouse_down("left"): - cell = self._tilemap_cell_under_mouse(model) - if cell is not None and cell not in self._tilemap_drag_cells: - self._tilemap_drag_cells.append(cell) - if input_manager.mouse_released("left"): - cells = self._tilemap_drag_cells - self._tilemap_drag_cells = None - if self.tilemap_tool == "erase" or self._read_tile_id(model): - command = model.erase_command(cells) if self.tilemap_tool == "erase" else model.paint_command(cells) - self._apply_tilemap_command(command) - return - if not self._mouse_inside_viewport_canvas() or input_manager.mouse_down("middle"): - return - if input_manager.mouse_pressed("left"): - if self.tilemap_tool == "fill": - if self._read_tile_id(model): - self._apply_tilemap_command(model.fill_command()) - else: - cell = self._tilemap_cell_under_mouse(model) - if cell is not None: - self._tilemap_drag_cells = [cell] - - def _update_viewport_input(self) -> None: - if self.game.renderer is None or self._any_dialog_open(): - return - input_manager = self.game.input - if input_manager.key_pressed("home"): - self.viewport_state.reset() - self.status_label.text = "Viewport reset" - if not self._mouse_inside_viewport_canvas(): - return - mouse_x, mouse_y = self._viewport_mouse_centered() - center_x, center_y = self.viewport_canvas.calculate_position() - if input_manager.mouse_down("middle"): - delta_x, delta_y = input_manager.mouse_delta - self.viewport_state.pan_screen_delta(delta_x, delta_y) - _wheel_x, wheel_y = input_manager.wheel - if wheel_y: - self.viewport_state.zoom_at(mouse_x, mouse_y, center_x, center_y, 1.15 ** float(wheel_y)) - - # ---------------------------------------------------------- - # Refresh, undo and layout - # ---------------------------------------------------------- - - def _refresh_tool_ui(self) -> None: - model = self._selected_tilemap_editor_model() - enabled = model is not None and bool(model.layer_names) - for control in (self.select_button, self.paint_button, self.erase_button, self.fill_button, self.projection_button, self.tile_input): - control.enabled = enabled - for control in ( - self.layer_name_input, - self.layer_role_dropdown, - self.layer_visible_checkbox, - self.layer_enabled_checkbox, - self.layer_render_input, - self.layer_opacity_input, - self.layer_y_sort_checkbox, - ): - control.enabled = enabled - if model is None: - self.tool_label.text = "No configured TileMap" - self.layer_list.set_items([]) - self.tile_palette.set_tileset(None) - self.info_label.text = "No TileMap asset selected.\nChoose a tileset image to begin." - self.tileset_path_label.text = "No tileset selected" - self.layer_name_input.set_text("", emit=False) - self.layer_render_input.set_text("", emit=False) - self.layer_opacity_input.set_text("", emit=False) - self.layer_role_hint.text = "Select a layer to edit its role and properties." - return - layer = model.active_layer - self.layer_name_input.set_text(layer.name, emit=False) - self.layer_render_input.set_text(str(layer.render_layer), emit=False) - self.layer_opacity_input.set_text(f"{layer.opacity:.2f}", emit=False) - self.layer_visible_checkbox.set_checked(layer.visible, emit=False) - self.layer_enabled_checkbox.set_checked(layer.enabled, emit=False) - self.layer_y_sort_checkbox.set_checked(layer.y_sort, emit=False) - role = normalize_layer_role(layer.role, name=layer.name) - role_index = LAYER_ROLES.index(role) if role in LAYER_ROLES else 0 - self.layer_role_dropdown.set_selected_index(role_index, emit=False) - role_hints = { - "ground": "Base walkable/world surface.", - "decoration": "Visual decoration without gameplay collision.", - "objects": "Gameplay objects and interactables.", - "foreground": "Rendered in front of actors.", - "background": "Rendered behind the ground.", - "collision": "Used by TileMap collision queries.", - "trigger": "Used by trigger/teleport/interaction queries.", - "navigation": "Used for navigation and pathfinding data.", - "custom": "Custom-purpose layer.", - } - self.layer_role_hint.text = role_hints.get(role, role_hints["custom"]) - self.tool_label.text = f"Tool: {self.tilemap_tool.title()} | Projection: {model.tilemap.projection.value}" - self.layer_list.set_items( - [f"{layer.name} [{normalize_layer_role(layer.role, name=layer.name).title()}]" for layer in model.tilemap.layers] - ) - if model.active_layer_name in model.layer_names: - self.layer_list.set_selected_index(model.layer_names.index(model.active_layer_name), emit=False) - self.tile_palette.set_tileset(model.tileset, model.node.texture) - selected = model.selected_tile if model.selected_tile is not None else 0 - if model.tileset.tile_count: - self.tile_palette.set_selected_index(min(selected, model.tileset.tile_count - 1), emit=False) - self.tile_input.set_text(str(selected), emit=False) - self.map_width_input.set_text(str(model.tilemap.width), emit=False) - self.map_height_input.set_text(str(model.tilemap.height), emit=False) - self.tile_width_input.set_text(str(model.tilemap.tile_width), emit=False) - self.tile_height_input.set_text(str(model.tilemap.tile_height), emit=False) - self.gap_x_input.set_text(str(model.tileset.spacing_x), emit=False) - self.gap_y_input.set_text(str(model.tileset.spacing_y), emit=False) - self.margin_x_input.set_text(str(model.tileset.margin_x), emit=False) - self.margin_y_input.set_text(str(model.tileset.margin_y), emit=False) - self.tileset_path_label.text = model.tileset.texture_asset or "No tileset selected" - self.info_label.text = ( - f"Map: {model.tilemap.name or self.document.display_name}\n" - f"Grid: {model.tilemap.width} x {model.tilemap.height} | " - f"{model.tilemap.tile_width} x {model.tilemap.tile_height}\n" - f"Projection: {model.tilemap.projection.value}\n" - f"Layers: {len(model.layer_names)}" - ) - - def _refresh_document_ui(self) -> None: - if not hasattr(self, "status_label"): - return - self.document_label.text = f"{self.document.title} | {self.project_path.name}" - self.viewport_subtitle.text = f"Editing: {self.document.display_name}" - self._refresh_tool_ui() - self.undo_button.enabled = self.commands.can_undo - self.redo_button.enabled = self.commands.can_redo - - def _on_selection_changed(self, node, _previous) -> None: - if node is not None and not isinstance(node, TileMapNode): - self.selection.clear() - self._refresh_document_ui() - - def undo(self) -> None: - if self.commands.undo() is not None: - self.document.mark_dirty() - self._refresh_document_ui() - self.status_label.text = "Undo" - - def redo(self) -> None: - if self.commands.redo() is not None: - self.document.mark_dirty() - self._refresh_document_ui() - self.status_label.text = "Redo" - - def _any_dialog_open(self) -> bool: - return any( - bool(getattr(getattr(self, name, None), "is_open", False)) - for name in ( - "new_map_dialog", - "open_dialog", - "save_dialog", - "tileset_dialog", - "tileset_setup_dialog", - "unsaved_dialog", - "error_dialog", - ) - ) - - def update(self, delta_time: float) -> None: - super().update(delta_time) - self._sync_layout() - self._update_viewport_input() - self._update_tilemap_input() - - def render(self, renderer) -> None: - self._sync_layout() - super().render(renderer) - - def _sync_layout(self) -> None: - renderer = self.game.renderer - if renderer is None: - return - width, height = float(renderer.width), float(renderer.height) - if (width, height) == self._last_size: - return - self._last_size = (width, height) - self.background.size = (width, height) - self.background.anchor = (0.5, 0.5) - self.background.pivot = (0.5, 0.5) - self.toolbar.size = (width, self.TOOLBAR_HEIGHT) - self.toolbar.anchor = (0.5, 0.0) - self.toolbar.pivot = (0.5, 0.0) - self.toolbar.position = (0.0, self.TOOLBAR_HEIGHT / 2.0) - self.status_bar.size = (width, self.STATUS_HEIGHT) - self.status_bar.anchor = (0.5, 1.0) - self.status_bar.pivot = (0.5, 1.0) - self.status_bar.position = (0.0, -self.STATUS_HEIGHT / 2.0) - body_top = self.TOOLBAR_HEIGHT + self.GAP - body_bottom = height - self.STATUS_HEIGHT - self.GAP - body_height = max(0.0, body_bottom - body_top) - bottom_h = min(self.BOTTOM_HEIGHT, max(130.0, body_height * 0.30)) - upper_h = max(0.0, body_height - bottom_h - self.GAP) - left_w = min(self.LEFT_WIDTH, width * 0.24) - right_w = min(self.RIGHT_WIDTH, width * 0.26) - center_w = max(0.0, width - left_w - right_w - self.GAP * 2.0) - for panel, panel_w, x in ( - (self.map_panel, left_w, left_w / 2.0), - (self.viewport, center_w, left_w + self.GAP + center_w / 2.0), - (self.info_panel, right_w, left_w + self.GAP + center_w + self.GAP + right_w / 2.0), - ): - panel.size = (panel_w, upper_h) - panel.anchor = (0.0, 0.0) - panel.pivot = (0.0, 0.0) - panel.position = (x, body_top + upper_h / 2.0) - self.bottom_dock.size = (width, bottom_h) - self.bottom_dock.anchor = (0.0, 0.0) - self.bottom_dock.pivot = (0.0, 0.0) - self.bottom_dock.position = (width / 2.0, body_top + upper_h + self.GAP + bottom_h / 2.0) - self._sync_toolbar(width) - self._sync_map_panel(left_w, upper_h) - self._sync_viewport(center_w, upper_h) - self._sync_info_panel(right_w, upper_h) - self._sync_tileset_setup_layout() - self._sync_bottom(width, bottom_h) - self.brand_label.position = (16.0, 0.0) - self.document_label.position = (-16.0, 0.0) - self.status_label.position = (12.0, 0.0) - self.version_label.position = (-12.0, 0.0) - - def _sync_toolbar(self, width: float) -> None: - del width - buttons = ( - (self.new_button, 66.0), (self.open_button, 74.0), (self.save_button, 72.0), - (self.save_as_button, 92.0), (self.tileset_button, 78.0), (self.scene_editor_button, 112.0), - (self.undo_button, 72.0), (self.redo_button, 72.0), - ) - x = 105.0 - for button, button_width in buttons: - button.size = (button_width, 34.0) - button.anchor = (0.0, 0.5) - button.pivot = (0.5, 0.5) - button.position = (x + button_width / 2.0, 0.0) - x += button_width + 6.0 - - def _sync_map_panel(self, width: float, height: float) -> None: - self.map_title.position = (12.0, 12.0) - self.layer_list.size = (max(0.0, width - 16.0), max(0.0, height - 120.0)) - self.layer_list.anchor = (0.0, 0.0) - self.layer_list.pivot = (0.5, 0.5) - self.layer_list.position = (width / 2.0, 48.0 + max(0.0, height - 120.0) / 2.0) - button_y = height - 54.0 - button_width = max(0.0, width / 2.0 - 18.0) - for button, x in ( - (self.add_layer_button, width * 0.25), - (self.remove_layer_button, width * 0.75), - ): - button.size = (button_width, 28.0) - button.anchor = (0.0, 0.0) - button.pivot = (0.5, 0.5) - button.position = (x, button_y) - self.layer_hint.position = (12.0, height - 20.0) - - def _sync_viewport(self, width: float, height: float) -> None: - self.viewport_canvas.size = (width, height) - self.viewport_canvas.anchor = (0.5, 0.5) - self.viewport_canvas.pivot = (0.5, 0.5) - self.viewport_canvas.position = (0.0, 0.0) - self.viewport_title.position = (12.0, 10.0) - self.viewport_subtitle.position = (12.0, 30.0) - self.viewport_controls.anchor = (1.0, 0.0) - self.viewport_controls.pivot = (1.0, 0.0) - self.viewport_controls.position = (-12.0, 10.0) - self.tool_label.position = (12.0, 54.0) - y = 78.0 - row_x = 12.0 - for button, button_width in ( - (self.select_button, 70.0), (self.paint_button, 70.0), - (self.erase_button, 70.0), (self.fill_button, 70.0), - ): - button.size = (button_width, 30.0) - button.anchor = (0.0, 0.0) - button.pivot = (0.5, 0.5) - button.position = (row_x + button_width / 2.0, y) - row_x += button_width + 6.0 - self.tile_input.size = (78.0, 30.0) - self.tile_input.anchor = (0.0, 0.0) - self.tile_input.pivot = (0.5, 0.5) - self.tile_input.position = (row_x + 39.0, y) - self.projection_button.size = (105.0, 30.0) - self.projection_button.anchor = (1.0, 0.0) - self.projection_button.pivot = (0.5, 0.5) - self.projection_button.position = (width - 66.0, y) - - def _sync_info_panel(self, width: float, height: float) -> None: - del height - content_width = max(0.0, width - 24.0) - column_gap = 8.0 - half_width = max(0.0, (content_width - column_gap) / 2.0) - left_column_center = 12.0 + half_width / 2.0 - right_column_center = width - 12.0 - half_width / 2.0 - self.info_title.position = (12.0, 12.0) - self.info_label.position = (12.0, 48.0) - self.info_label.size = (content_width, 70.0) - - self.layer_inspector_title.position = (12.0, 116.0) - self.layer_name_label.position = (12.0, 140.0) - self.layer_name_input.size = (content_width, 30.0) - self.layer_name_input.anchor = (0.0, 0.0) - self.layer_name_input.pivot = (0.5, 0.5) - self.layer_name_input.position = (width / 2.0, 162.0) - self.layer_role_label.position = (12.0, 184.0) - self.layer_role_dropdown.size = (content_width, 30.0) - self.layer_role_dropdown.anchor = (0.0, 0.0) - self.layer_role_dropdown.pivot = (0.5, 0.5) - self.layer_role_dropdown.position = (width / 2.0, 206.0) - for checkbox, x in ( - (self.layer_visible_checkbox, left_column_center), - (self.layer_enabled_checkbox, right_column_center), - ): - checkbox.size = (half_width, 30.0) - checkbox.anchor = (0.0, 0.0) - checkbox.pivot = (0.5, 0.5) - checkbox.position = (x, 248.0) - self.layer_render_label.position = (12.0, 278.0) - self.layer_render_input.size = (half_width, 30.0) - self.layer_render_input.anchor = (0.0, 0.0) - self.layer_render_input.pivot = (0.5, 0.5) - self.layer_render_input.position = (left_column_center, 300.0) - self.layer_opacity_label.position = (12.0 + half_width + column_gap, 278.0) - self.layer_opacity_input.size = (half_width, 30.0) - self.layer_opacity_input.anchor = (0.0, 0.0) - self.layer_opacity_input.pivot = (0.5, 0.5) - self.layer_opacity_input.position = (right_column_center, 300.0) - self.layer_y_sort_checkbox.size = (half_width, 30.0) - self.layer_y_sort_checkbox.anchor = (0.0, 0.0) - self.layer_y_sort_checkbox.pivot = (0.5, 0.5) - self.layer_y_sort_checkbox.position = (left_column_center, 338.0) - self.layer_role_hint.position = (12.0, 370.0) - - self.config_title.position = (12.0, 392.0) - self.tileset_path_label.position = (12.0, 418.0) - for button, x in ( - (self.apply_tileset_button, left_column_center), - (self.apply_map_button, right_column_center), - ): - button.size = (half_width, 28.0) - button.anchor = (0.0, 0.0) - button.pivot = (0.5, 0.5) - button.position = (x, 448.0) - fields = ( - ("MapWidth", self.map_width_input), ("MapHeight", self.map_height_input), - ("TileWidth", self.tile_width_input), ("TileHeight", self.tile_height_input), - ("GapX", self.gap_x_input), ("GapY", self.gap_y_input), - ("MarginX", self.margin_x_input), ("MarginY", self.margin_y_input), - ) - for index, (name, input_node) in enumerate(fields): - column = index % 2 - row = index // 2 - x = ( - left_column_center - if column == 0 - else right_column_center - ) - label_x = ( - 12.0 - if column == 0 - else 12.0 + half_width + column_gap - ) - # Labels sit above their controls. A 38px row step keeps the - # four setting rows readable without letting the last row fall - # behind the bottom dock at the default window size. - y = 466.0 + row * 38.0 - label = self._config_labels[name] - label.position = (label_x, y) - input_node.size = (half_width, 24.0) - input_node.anchor = (0.0, 0.0) - input_node.pivot = (0.5, 0.5) - input_node.position = (x, y + 20.0) - - def _sync_tileset_setup_layout(self) -> None: - """Arrange the tileset setup wizard inside its dialog content.""" - content = self.tileset_setup_dialog.content - width, height = content.size - if width <= 0.0 or height <= 0.0: - width, height = (644.0, 268.0) - padding = 16.0 - column_gap = 18.0 - column_width = max(0.0, (width - padding * 2.0 - column_gap) / 2.0) - left = -width / 2.0 + padding - right = left + column_width + column_gap - top = -height / 2.0 + 12.0 - - for label in ( - self.tileset_setup_info, - self.tileset_setup_error, - self.tileset_setup_summary, - self.tileset_setup_tile_width_label, - self.tileset_setup_tile_height_label, - self.tileset_setup_gap_x_label, - self.tileset_setup_gap_y_label, - self.tileset_setup_margin_x_label, - self.tileset_setup_margin_y_label, - ): - label.anchor = (0.0, 0.0) - label.pivot = (0.0, 0.0) - - self.tileset_setup_info.position = (left, top) - fields = ( - ("tile_width", self.tileset_setup_tile_width_label), - ("tile_height", self.tileset_setup_tile_height_label), - ("gap_x", self.tileset_setup_gap_x_label), - ("gap_y", self.tileset_setup_gap_y_label), - ("margin_x", self.tileset_setup_margin_x_label), - ("margin_y", self.tileset_setup_margin_y_label), - ) - for index, (name, label) in enumerate(fields): - column = index % 2 - row = index // 2 - column_left = left if column == 0 else right - center = column_left + column_width / 2.0 - y = top + 46.0 + row * 52.0 - label.position = (column_left, y) - input_node = self.tileset_setup_inputs[name] - input_node.size = (column_width, 30.0) - input_node.anchor = (0.0, 0.0) - input_node.pivot = (0.5, 0.5) - input_node.position = (center, y + 22.0) - - self.tileset_setup_summary.position = (left, top + 216.0) - self.tileset_setup_error.position = (left, top + 240.0) - - def _sync_bottom(self, width: float, height: float) -> None: - self.palette_title.position = (12.0, 12.0) - self.tile_palette.size = (max(0.0, width - 24.0), max(0.0, height - 54.0)) - self.tile_palette.anchor = (0.0, 0.0) - self.tile_palette.pivot = (0.5, 0.5) - self.tile_palette.position = (width / 2.0, 54.0 + max(0.0, height - 54.0) / 2.0) - self.palette_hint.position = (width - 12.0, 18.0) - self.palette_hint.anchor = (1.0, 0.0) - self.palette_hint.pivot = (1.0, 0.0) - - -class TileMapEditorApp(Game): - """Nexora application wrapper for the standalone TileMap editor.""" - - def __init__(self, *, project_path: str | Path | None = None, tilemap_path: str | Path | None = None) -> None: - self.tilemap_project_path = resolve_project_path(project_path) - self.tilemap_asset_path = tilemap_path - super().__init__( - project_name="NexoraTileMapEditor", - title=f"Nexora TileMap Editor - {self.tilemap_project_path.name}", - width=1440, - height=900, - resizable=True, - editor_mode=True, - ) - self.tilemap_editor_scene = None - - def initialize(self) -> None: - # AssetManager defaults to the process working directory. The - # standalone editor can open another project, so bind asset loading - # to that project's assets directory before any TileMap is created. - if self.engine is not None: - assets_root = self.tilemap_project_path / "assets" - self.engine.assets.root = ( - assets_root if assets_root.is_dir() else self.tilemap_project_path - ) - icon_path = self.tilemap_project_path / "assets" / "icon.png" - if self.window is not None and icon_path.is_file(): - try: - self.window.set_icon(icon_path) - except Exception: - # A missing/unsupported platform icon must not prevent the - # editor itself from starting. - pass - document = EditorDocument() - scene = TileMapEditorScene( - self, - self.tilemap_project_path, - document=document, - selection=SelectionService(), - ) - self.tilemap_editor_scene = scene - self.scene = scene - scene._create_new_map() - if self.tilemap_asset_path is not None: - scene.open_path(self.tilemap_asset_path) - - -def run_tilemap_editor( - project_path: str | Path | None = None, - tilemap_path: str | Path | None = None, -) -> int: - try: - app = TileMapEditorApp(project_path=project_path, tilemap_path=tilemap_path) - except (FileNotFoundError, NotADirectoryError) as exc: - print(f"[Nexora TileMap Editor] {exc}") - return 2 - app.run() - return 0 - __all__ = ["TileMapEditorApp", "TileMapEditorScene", "run_tilemap_editor"] diff --git a/nexora/editor/tools.py b/nexora/editor/tools.py index c2b0373..2e9dc84 100644 --- a/nexora/editor/tools.py +++ b/nexora/editor/tools.py @@ -6,6 +6,7 @@ from nexora import Game from nexora.editor.app import resolve_project_path +from nexora.editor.model import EditorProjectContext from nexora.editor.theme import ( ACCENT, BUTTON_BACKGROUND, @@ -25,10 +26,16 @@ class EditorToolsScene(Scene): CARD_WIDTH = 560.0 CARD_HEIGHT = 520.0 - def __init__(self, game, project_path: Path) -> None: + def __init__( + self, + game, + project_path: Path, + project_context: EditorProjectContext | None = None, + ) -> None: super().__init__("NexoraEditorTools") self.game = game - self.project_path = Path(project_path).resolve() + self.project_context = project_context or EditorProjectContext.from_path(project_path) + self.project_path = self.project_context.root self._last_viewport = (-1.0, -1.0) self._build_ui() @@ -228,6 +235,9 @@ class EditorToolsApp(Game): def __init__(self, *, project_path: str | Path | None = None) -> None: self.editor_tools_project_path = resolve_project_path(project_path) + self.editor_tools_project_context = EditorProjectContext.from_path( + self.editor_tools_project_path + ) super().__init__( project_name="NexoraEditorTools", title=f"Nexora Editor Tools - {self.editor_tools_project_path.name}", @@ -239,7 +249,7 @@ def __init__(self, *, project_path: str | Path | None = None) -> None: self.editor_tools_scene: EditorToolsScene | None = None def initialize(self) -> None: - icon_path = self.editor_tools_project_path / "assets" / "icon.png" + icon_path = self.editor_tools_project_context.icon_path if self.window is not None and icon_path.is_file(): try: self.window.set_icon(icon_path) @@ -249,6 +259,7 @@ def initialize(self) -> None: self.editor_tools_scene = EditorToolsScene( self, self.editor_tools_project_path, + self.editor_tools_project_context, ) self.scene = self.editor_tools_scene diff --git a/nexora/editor/ui/__init__.py b/nexora/editor/ui/__init__.py new file mode 100644 index 0000000..e45969c --- /dev/null +++ b/nexora/editor/ui/__init__.py @@ -0,0 +1,46 @@ +"""Shared renderer-backed UI primitives used by standalone editors. + +The standalone tilemap, item and cutscene editors all use the same small +widget toolkit. Keeping the public surface here gives editor code one stable +import path and keeps the implementation details in :mod:`widgets`. +""" + +from .widgets import ( + Button, + CategorizedListBox, + CheckBox, + Control, + Dropdown, + ListBox, + Menu, + Rect, + TextField, + UITheme, + draw_outline, + draw_rect, + draw_text, + rgba, +) +from .browser import FileBrowserModel, sync_browser_list +from .layout import centered_rect, close_other_menus + +__all__ = [ + "Button", + "CategorizedListBox", + "CheckBox", + "Control", + "Dropdown", + "ListBox", + "Menu", + "Rect", + "TextField", + "UITheme", + "draw_outline", + "draw_rect", + "draw_text", + "rgba", + "FileBrowserModel", + "sync_browser_list", + "centered_rect", + "close_other_menus", +] diff --git a/nexora/editor/ui/browser.py b/nexora/editor/ui/browser.py new file mode 100644 index 0000000..9e54656 --- /dev/null +++ b/nexora/editor/ui/browser.py @@ -0,0 +1,122 @@ +"""Shared filesystem browser state for standalone editor dialogs.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Iterable + + +DEFAULT_HIDDEN_DIRECTORIES = frozenset( + {".git", ".venv", "__pycache__", "build", "dist", "Bruch", "MyGame"} +) + + +class FileBrowserModel: + """Project-scoped directory browser used by all standalone editors. + + The model owns navigation, filtering and the display order. Editors only + decide what a selected file means (open, save, icon, image or audio), so + that platform-specific ``[..]`` handling is implemented once. + """ + + def __init__(self, *, hidden_directories: Iterable[str] = DEFAULT_HIDDEN_DIRECTORIES) -> None: + self.hidden_directories = frozenset(str(name) for name in hidden_directories) + self.root = Path.cwd().resolve() + self.path = self.root + self.extensions: tuple[str, ...] = () + self.entries: list[Path] = [] + self.labels: list[str] = [] + + def open( + self, + root: str | Path, + *, + start: str | Path | None = None, + extensions: Iterable[str] = (), + ) -> None: + self.root = Path(root).expanduser().resolve() + requested_path = Path(start).expanduser() if start is not None else self.root + self.path = self._confine(requested_path) + self.extensions = tuple(str(extension).lower() for extension in extensions) + self.refresh() + + def refresh(self) -> tuple[str, ...]: + try: + entries = list(self.path.iterdir()) + except (FileNotFoundError, OSError): + entries = [] + + directories = sorted( + ( + entry + for entry in entries + if entry.is_dir() and entry.name not in self.hidden_directories + ), + key=lambda entry: entry.name.lower(), + ) + files = sorted( + ( + entry + for entry in entries + if entry.is_file() and self._matches_extension(entry) + ), + key=lambda entry: entry.name.lower(), + ) + + self.entries = [] + self.labels = [] + if self.path != self.root: + self.entries.append(self.path.parent) + self.labels.append("[..]") + self.entries.extend(directories) + self.labels.extend(f"[{entry.name}]" for entry in directories) + self.entries.extend(files) + self.labels.extend(entry.name for entry in files) + return tuple(self.labels) + + def select(self, index: int) -> Path | None: + """Navigate or return the selected file. + + ``None`` means the selection was navigation or invalid. A returned + path is always a file currently visible in the browser. + """ + + if not 0 <= int(index) < len(self.entries): + return None + path = self.entries[int(index)] + if self.path != self.root and int(index) == 0: + self.go_up() + return None + if path.is_dir(): + self.path = self._confine(path) + self.refresh() + return None + return path.resolve() + + def go_up(self) -> bool: + if self.path == self.root: + return False + self.path = self._confine(self.path.parent) + self.refresh() + return True + + def _matches_extension(self, path: Path) -> bool: + if not self.extensions: + return True + name = path.name.lower() + return any(name.endswith(extension) for extension in self.extensions) + + def _confine(self, path: Path) -> Path: + candidate = path.resolve() + try: + candidate.relative_to(self.root) + except ValueError: + return self.root + return candidate + + +def sync_browser_list(browser: FileBrowserModel, list_box) -> None: + """Copy browser labels into the shared renderer-backed list widget.""" + + list_box.set_items(browser.labels) + list_box.selected = -1 diff --git a/nexora/editor/ui/layout.py b/nexora/editor/ui/layout.py new file mode 100644 index 0000000..67fd6c3 --- /dev/null +++ b/nexora/editor/ui/layout.py @@ -0,0 +1,25 @@ +"""Small layout helpers shared by standalone editor screens.""" + +from __future__ import annotations + +from collections.abc import Iterable + +from .widgets import Rect + + +def centered_rect(viewport: tuple[float, float], width: float, height: float) -> Rect: + screen_width, screen_height = viewport + return Rect( + float(screen_width) / 2.0 - float(width) / 2.0, + float(screen_height) / 2.0 - float(height) / 2.0, + float(width), + float(height), + ) + + +def close_other_menus(menus: Iterable[object], active: object) -> None: + """Close every menu except the one that was just opened.""" + + for menu in menus: + if menu is not active: + menu.open = False diff --git a/nexora/editor/ui/widgets.py b/nexora/editor/ui/widgets.py new file mode 100644 index 0000000..9656ec5 --- /dev/null +++ b/nexora/editor/ui/widgets.py @@ -0,0 +1,537 @@ +"""Small renderer-backed UI toolkit shared by standalone editors. + +This module deliberately does not use Nexora's UINode tree. Controls use +top-left screen rectangles while the renderer itself remains in centered +coordinates. That keeps layout math explicit and makes the standalone +editors independent from the regular editor UI. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable + + +Color = tuple[int, int, int, int] + +# Global readability multiplier for the standalone editor UI. Change this +# value once to adjust all labels, buttons and field text consistently. +UI_SCALE = 1.50 + + +@dataclass(slots=True) +class UITheme: + background: Color = (17, 19, 23, 255) + panel: Color = (29, 32, 38, 255) + panel_dark: Color = (22, 25, 30, 255) + border: Color = (61, 67, 78, 255) + input: Color = (35, 39, 47, 255) + input_focus: Color = (43, 49, 61, 255) + button: Color = (38, 43, 52, 255) + button_hover: Color = (52, 61, 76, 255) + accent: Color = (84, 142, 235, 255) + text: Color = (235, 237, 242, 255) + muted: Color = (154, 161, 174, 255) + warning: Color = (240, 174, 86, 255) + error: Color = (238, 112, 112, 255) + selected: Color = (50, 82, 125, 255) + + +@dataclass(slots=True) +class Rect: + x: float + y: float + width: float + height: float + + def contains(self, x: float, y: float) -> bool: + return ( + self.x <= float(x) <= self.x + self.width + and self.y <= float(y) <= self.y + self.height + ) + + def center(self) -> tuple[float, float]: + return (self.x + self.width / 2.0, self.y + self.height / 2.0) + + +def rgba(color: Color) -> tuple[float, float, float, float]: + return tuple(channel / 255.0 for channel in color) + + +def _world_point(x: float, y: float, width: float, height: float) -> tuple[float, float]: + return (float(x) - width / 2.0, float(y) - height / 2.0) + + +def draw_rect(renderer, rect: Rect, color: Color, viewport: tuple[float, float], *, radius: float = 0.0) -> None: + width, height = viewport + x, y = _world_point(rect.x + rect.width / 2.0, rect.y + rect.height / 2.0, width, height) + renderer.rect(x, y, rect.width, rect.height, color=rgba(color), radius=radius) + + +def draw_outline(renderer, rect: Rect, color: Color, viewport: tuple[float, float], *, width: float = 1.0) -> None: + screen_width, screen_height = viewport + left, top = _world_point(rect.x, rect.y, screen_width, screen_height) + right, bottom = _world_point(rect.x + rect.width, rect.y + rect.height, screen_width, screen_height) + renderer.line(left, top, right, top, width=width, color=rgba(color)) + renderer.line(right, top, right, bottom, width=width, color=rgba(color)) + renderer.line(right, bottom, left, bottom, width=width, color=rgba(color)) + renderer.line(left, bottom, left, top, width=width, color=rgba(color)) + + +def draw_text( + renderer, + text: str, + x: float, + y: float, + viewport: tuple[float, float], + *, + scale: float = 0.65, + color: Color = (235, 237, 242, 255), + align: str = "left", +) -> None: + if not text: + return + scale = float(scale) * UI_SCALE + width, height = viewport + lines = str(text).splitlines() or [""] + text_renderer = getattr(getattr(renderer, "gpu", None), "text_renderer", None) + previous = None + if text_renderer is not None: + previous = text_renderer.color + text_renderer.set_color(*rgba(color)) + try: + for index, line in enumerate(lines): + line_x = float(x) + if align != "left": + measured_width, _ = renderer.text_measure(line, scale=scale) + if align == "center": + line_x -= measured_width / 2.0 + elif align == "right": + line_x -= measured_width + world_x, world_y = _world_point(line_x, y, width, height) + renderer.text(line, world_x, world_y + renderer.text_baseline(scale=scale), scale=scale) + y += renderer.text_measure("Ag", scale=scale)[1] + finally: + if text_renderer is not None and previous is not None: + text_renderer.set_color(*previous) + + +class Control: + def __init__(self, rect: Rect) -> None: + self.rect = rect + self.visible = True + self.enabled = True + self.hovered = False + + def update(self, input_manager, mouse_x: float, mouse_y: float) -> bool: + self.hovered = self.visible and self.rect.contains(mouse_x, mouse_y) + return False + + def render(self, renderer, viewport: tuple[float, float], theme: UITheme) -> None: + del renderer, viewport, theme + + +class Button(Control): + def __init__(self, rect: Rect, text: str, on_click: Callable[[], None] | None = None) -> None: + super().__init__(rect) + self.text = str(text) + self.on_click = on_click + self.pressed = False + self.selected = False + self.text_scale = 0.62 + + def update(self, input_manager, mouse_x: float, mouse_y: float) -> bool: + super().update(input_manager, mouse_x, mouse_y) + self.pressed = bool(self.hovered and input_manager.mouse_down("left")) + if self.enabled and self.hovered and input_manager.mouse_pressed("left"): + if self.on_click is not None: + self.on_click() + return True + return False + + def render(self, renderer, viewport, theme: UITheme) -> None: + if not self.visible: + return + color = theme.selected if self.selected else (theme.button_hover if self.hovered else theme.button) + if not self.enabled: + color = theme.panel_dark + draw_rect(renderer, self.rect, color, viewport, radius=4.0) + draw_outline(renderer, self.rect, theme.accent if self.pressed else theme.border, viewport) + text_height = renderer.text_measure("Ag", scale=self.text_scale * UI_SCALE)[1] + draw_text( + renderer, + self.text, + self.rect.x + self.rect.width / 2.0, + self.rect.y + (self.rect.height - text_height) / 2.0, + viewport, + scale=self.text_scale, + color=theme.text if self.enabled else theme.muted, + align="center", + ) + + +class Menu(Control): + """Compact top-bar menu with a renderer-backed drop-down list.""" + + def __init__(self, rect: Rect, title: str, items: list[tuple[str, Callable[[], None]]]) -> None: + super().__init__(rect) + self.title = str(title) + self.items = [(str(label), callback) for label, callback in items] + self.open = False + self.item_height = 30.0 + self.menu_width = 220.0 + self.popup_origin: tuple[float, float] | None = None + self.on_open: Callable[["Menu"], None] | None = None + self._last_mouse = (-1.0, -1.0) + + @property + def dropdown_rect(self) -> Rect: + x, y = self.popup_origin or (self.rect.x, self.rect.y + self.rect.height) + return Rect(x, y, self.menu_width, self.item_height * len(self.items)) + + def update(self, input_manager, mouse_x: float, mouse_y: float) -> bool: + self._last_mouse = (float(mouse_x), float(mouse_y)) + self.hovered = self.visible and self.rect.contains(mouse_x, mouse_y) + if not self.visible or not input_manager.mouse_pressed("left"): + return False + if self.rect.contains(mouse_x, mouse_y): + self.open = not self.open + if self.open and self.on_open is not None: + self.on_open(self) + return True + if self.open: + dropdown = self.dropdown_rect + if dropdown.contains(mouse_x, mouse_y): + index = int((mouse_y - dropdown.y) // self.item_height) + if 0 <= index < len(self.items): + _label, callback = self.items[index] + self.open = False + callback() + return True + self.open = False + return True + return False + + def render(self, renderer, viewport, theme: UITheme) -> None: + if not self.visible: + return + draw_rect(renderer, self.rect, theme.button_hover if self.hovered or self.open else theme.button, viewport, radius=4.0) + draw_outline(renderer, self.rect, theme.accent if self.open else theme.border, viewport) + text_height = renderer.text_measure("Ag", scale=0.64 * UI_SCALE)[1] + draw_text(renderer, self.title, self.rect.x + self.rect.width / 2.0, self.rect.y + (self.rect.height - text_height) / 2.0, viewport, scale=0.64, align="center") + if not self.open: + return + dropdown = self.dropdown_rect + draw_rect(renderer, dropdown, theme.panel, viewport, radius=3.0) + draw_outline(renderer, dropdown, theme.accent, viewport) + for index, (label, _callback) in enumerate(self.items): + item = Rect(dropdown.x + 2.0, dropdown.y + index * self.item_height + 2.0, dropdown.width - 4.0, self.item_height - 4.0) + if item.contains(*self._last_mouse): + draw_rect(renderer, item, theme.selected, viewport, radius=2.0) + text_height = renderer.text_measure("Ag", scale=0.58 * UI_SCALE)[1] + draw_text(renderer, label, item.x + 10.0, item.y + (item.height - text_height) / 2.0, viewport, scale=0.58) + +class TextField(Control): + def __init__(self, rect: Rect, text: str = "", placeholder: str = "") -> None: + super().__init__(rect) + self.text = str(text) + self.placeholder = str(placeholder) + self.focused = False + self.caret = len(self.text) + self.on_submit: Callable[[str], None] | None = None + self.on_change: Callable[[str], None] | None = None + self.text_scale = 0.62 + + def set_text(self, text: str) -> None: + self.text = str(text) + self.caret = min(self.caret, len(self.text)) + + def focus(self, input_manager) -> None: + self.focused = True + try: + input_manager.acquire_text_input(self) + except Exception: + pass + + def blur(self, input_manager) -> None: + if not self.focused: + return + self.focused = False + try: + input_manager.release_text_input(self) + except Exception: + pass + + def _changed(self, value: str) -> None: + self.text = value + self.caret = max(0, min(self.caret, len(self.text))) + if self.on_change is not None: + self.on_change(self.text) + + def update(self, input_manager, mouse_x: float, mouse_y: float) -> bool: + super().update(input_manager, mouse_x, mouse_y) + if self.enabled and self.hovered and input_manager.mouse_pressed("left"): + self.focus(input_manager) + self.caret = len(self.text) + return True + if not self.focused: + return False + for value in input_manager.text_input: + self._changed(self.text[: self.caret] + str(value) + self.text[self.caret :]) + self.caret += len(str(value)) + if input_manager.key_pressed("backspace") and self.caret > 0: + self._changed(self.text[: self.caret - 1] + self.text[self.caret :]) + self.caret -= 1 + if input_manager.key_pressed("delete") and self.caret < len(self.text): + self._changed(self.text[: self.caret] + self.text[self.caret + 1 :]) + if input_manager.key_pressed("left"): + self.caret = max(0, self.caret - 1) + if input_manager.key_pressed("right"): + self.caret = min(len(self.text), self.caret + 1) + if input_manager.key_pressed("home"): + self.caret = 0 + if input_manager.key_pressed("end"): + self.caret = len(self.text) + if input_manager.key_pressed("return") and self.on_submit is not None: + self.on_submit(self.text) + return False + + def render(self, renderer, viewport, theme: UITheme) -> None: + if not self.visible: + return + draw_rect(renderer, self.rect, theme.input_focus if self.focused else theme.input, viewport, radius=4.0) + draw_outline(renderer, self.rect, theme.accent if self.focused else theme.border, viewport, width=2.0 if self.focused else 1.0) + value = self.text or self.placeholder + color = theme.text if self.text else theme.muted + draw_text(renderer, value, self.rect.x + 9.0, self.rect.y + 7.0, viewport, scale=self.text_scale, color=color) + if self.focused: + prefix = self.text[: self.caret] + prefix_width, _ = renderer.text_measure(prefix, scale=self.text_scale * UI_SCALE) + width, height = viewport + x, y = _world_point(self.rect.x + 9.0 + prefix_width, self.rect.y + 6.0, width, height) + renderer.line(x, y, x, y + self.rect.height - 12.0, width=1.0, color=rgba(theme.text)) + + +class CheckBox(Control): + def __init__(self, rect: Rect, text: str, checked: bool = False, on_change: Callable[[bool], None] | None = None) -> None: + super().__init__(rect) + self.text = str(text) + self.checked = bool(checked) + self.on_change = on_change + + def update(self, input_manager, mouse_x: float, mouse_y: float) -> bool: + super().update(input_manager, mouse_x, mouse_y) + if self.enabled and self.hovered and input_manager.mouse_pressed("left"): + self.checked = not self.checked + if self.on_change is not None: + self.on_change(self.checked) + return True + return False + + def render(self, renderer, viewport, theme: UITheme) -> None: + if not self.visible: + return + box = Rect(self.rect.x, self.rect.y + 2.0, 20.0, 20.0) + draw_rect(renderer, box, theme.accent if self.checked else theme.input, viewport, radius=3.0) + draw_outline(renderer, box, theme.border, viewport) + if self.checked: + draw_text(renderer, "✓", box.x + 3.0, box.y - 1.0, viewport, scale=0.65, color=theme.text) + draw_text(renderer, self.text, self.rect.x + 28.0, self.rect.y + 2.0, viewport, scale=0.60, color=theme.text) + + +class Dropdown(Control): + def __init__(self, rect: Rect, options: tuple[str, ...] | list[str], selected: int = 0, on_change: Callable[[int, str], None] | None = None) -> None: + super().__init__(rect) + self.options = tuple(str(option) for option in options) + self.selected = max(0, min(int(selected), len(self.options) - 1)) if self.options else -1 + self.on_change = on_change + + @property + def value(self) -> str: + return self.options[self.selected] if 0 <= self.selected < len(self.options) else "" + + def update(self, input_manager, mouse_x: float, mouse_y: float) -> bool: + super().update(input_manager, mouse_x, mouse_y) + if self.enabled and self.hovered and input_manager.mouse_pressed("left") and self.options: + self.selected = (self.selected + 1) % len(self.options) + if self.on_change is not None: + self.on_change(self.selected, self.value) + return True + return False + + def render(self, renderer, viewport, theme: UITheme) -> None: + if not self.visible: + return + draw_rect(renderer, self.rect, theme.input, viewport, radius=4.0) + draw_outline(renderer, self.rect, theme.accent if self.hovered else theme.border, viewport) + draw_text(renderer, self.value, self.rect.x + 9.0, self.rect.y + 7.0, viewport, scale=0.60, color=theme.text) + draw_text(renderer, "▾", self.rect.x + self.rect.width - 20.0, self.rect.y + 7.0, viewport, scale=0.60, color=theme.muted) + + +class ListBox(Control): + def __init__(self, rect: Rect, on_change: Callable[[int], None] | None = None) -> None: + super().__init__(rect) + self.items: list[str] = [] + self.selected = -1 + self.on_change = on_change + self.row_height = 30.0 + self.scroll = 0 + self.row_action: Callable[[int], None] | None = None + self.row_action_state: Callable[[int], bool] | None = None + self.row_action_width = 34.0 + self.scrollbar_width = 12.0 + + def set_items(self, items: list[str] | tuple[str, ...]) -> None: + self.items = [str(item) for item in items] + self.selected = min(self.selected, len(self.items) - 1) + visible_rows = max(1, int(self.rect.height // self.row_height)) + self.scroll = min(self.scroll, max(0, len(self.items) - visible_rows)) + + def update(self, input_manager, mouse_x: float, mouse_y: float) -> bool: + super().update(input_manager, mouse_x, mouse_y) + if not self.hovered: + return False + visible_rows = max(1, int(self.rect.height // self.row_height)) + max_scroll = max(0, len(self.items) - visible_rows) + _wheel_x, wheel_y = input_manager.wheel + if wheel_y: + self.scroll = max(0, min(max_scroll, self.scroll - int(wheel_y))) + return True + if input_manager.mouse_pressed("left"): + if max_scroll and mouse_x >= self.rect.x + self.rect.width - self.scrollbar_width: + fraction = (mouse_y - self.rect.y) / max(1.0, self.rect.height) + self.scroll = max(0, min(max_scroll, int(fraction * max_scroll))) + return True + index = int((mouse_y - self.rect.y) // self.row_height) + self.scroll + if 0 <= index < len(self.items): + if self.row_action is not None and mouse_x >= self.rect.x + self.rect.width - self.row_action_width: + self.row_action(index) + return True + self.selected = index + if self.on_change is not None: + self.on_change(index) + return True + return False + + def render(self, renderer, viewport, theme: UITheme) -> None: + if not self.visible: + return + draw_rect(renderer, self.rect, theme.panel_dark, viewport) + draw_outline(renderer, self.rect, theme.border, viewport) + visible_rows = max(1, int(self.rect.height // self.row_height)) + max_scroll = max(0, len(self.items) - visible_rows) + for row in range(visible_rows): + index = row + self.scroll + if index >= len(self.items): + break + row_rect = Rect(self.rect.x + 3.0, self.rect.y + row * self.row_height + 3.0, self.rect.width - 6.0, self.row_height - 3.0) + if index == self.selected: + draw_rect(renderer, row_rect, theme.selected, viewport, radius=3.0) + text_width = row_rect.width - self.scrollbar_width - (self.row_action_width if self.row_action is not None else 0.0) + draw_text(renderer, self.items[index], row_rect.x + 8.0, row_rect.y + 7.0, viewport, scale=0.58, color=theme.text) + if self.row_action is not None: + symbol = "◉" if self.row_action_state is None or self.row_action_state(index) else "○" + draw_text(renderer, symbol, row_rect.x + text_width + self.row_action_width / 2.0, row_rect.y + 5.0, viewport, scale=0.70, color=theme.accent if symbol == "◉" else theme.muted, align="center") + if max_scroll: + track = Rect(self.rect.x + self.rect.width - self.scrollbar_width + 2.0, self.rect.y + 3.0, self.scrollbar_width - 5.0, self.rect.height - 6.0) + draw_rect(renderer, track, theme.input, viewport, radius=3.0) + thumb_height = max(22.0, track.height * visible_rows / max(visible_rows, len(self.items))) + thumb_y = track.y + (track.height - thumb_height) * (self.scroll / max_scroll) + draw_rect(renderer, Rect(track.x, thumb_y, track.width, thumb_height), theme.accent, viewport, radius=3.0) + + +class CategorizedListBox(Control): + """Scrollable list with collapsible category headers.""" + + def __init__(self, rect: Rect, on_change: Callable[[int], None] | None = None) -> None: + super().__init__(rect) + self.groups: list[tuple[str, list[tuple[str, int]]]] = [] + self.rows: list[tuple[str, str, int | None]] = [] + self.collapsed: set[str] = set() + self.selected_asset = -1 + self.on_change = on_change + self.row_height = 30.0 + self.scroll = 0 + self.scrollbar_width = 12.0 + + def set_groups(self, groups: dict[str, list[tuple[str, int]]]) -> None: + self.collapsed.intersection_update(groups) + self.groups = [(name, list(groups[name])) for name in groups] + self._rebuild_rows() + if self.selected_asset not in { + asset_index + for _category, items in self.groups + for _label, asset_index in items + }: + self.selected_asset = -1 + + def _rebuild_rows(self) -> None: + self.rows = [] + for category, items in self.groups: + self.rows.append(("category", category, None)) + if category not in self.collapsed: + self.rows.extend(("item", label, asset_index) for label, asset_index in items) + visible_rows = max(1, int(self.rect.height // self.row_height)) + self.scroll = min(self.scroll, max(0, len(self.rows) - visible_rows)) + + def update(self, input_manager, mouse_x: float, mouse_y: float) -> bool: + super().update(input_manager, mouse_x, mouse_y) + if not self.hovered: + return False + visible_rows = max(1, int(self.rect.height // self.row_height)) + max_scroll = max(0, len(self.rows) - visible_rows) + _wheel_x, wheel_y = input_manager.wheel + if wheel_y: + self.scroll = max(0, min(max_scroll, self.scroll - int(wheel_y))) + return True + if not input_manager.mouse_pressed("left"): + return False + if max_scroll and mouse_x >= self.rect.x + self.rect.width - self.scrollbar_width: + fraction = (mouse_y - self.rect.y) / max(1.0, self.rect.height) + self.scroll = max(0, min(max_scroll, int(fraction * max_scroll))) + return True + row_index = int((mouse_y - self.rect.y) // self.row_height) + self.scroll + if not 0 <= row_index < len(self.rows): + return True + row_type, label, asset_index = self.rows[row_index] + if row_type == "category": + if label in self.collapsed: + self.collapsed.remove(label) + else: + self.collapsed.add(label) + self._rebuild_rows() + return True + if asset_index is not None: + self.selected_asset = asset_index + if self.on_change is not None: + self.on_change(asset_index) + return True + + def render(self, renderer, viewport, theme: UITheme) -> None: + if not self.visible: + return + draw_rect(renderer, self.rect, theme.panel_dark, viewport) + draw_outline(renderer, self.rect, theme.border, viewport) + visible_rows = max(1, int(self.rect.height // self.row_height)) + max_scroll = max(0, len(self.rows) - visible_rows) + category_counts = {name: len(items) for name, items in self.groups} + for row in range(visible_rows): + index = row + self.scroll + if index >= len(self.rows): + break + row_type, label, asset_index = self.rows[index] + row_rect = Rect(self.rect.x + 3.0, self.rect.y + row * self.row_height + 3.0, self.rect.width - 6.0, self.row_height - 3.0) + if row_type == "category": + draw_rect(renderer, row_rect, theme.button, viewport, radius=3.0) + symbol = "▸" if label in self.collapsed else "▾" + draw_text(renderer, f"{symbol} {label} ({category_counts.get(label, 0)})", row_rect.x + 8.0, row_rect.y + 7.0, viewport, scale=0.56, color=theme.text) + else: + if asset_index == self.selected_asset: + draw_rect(renderer, row_rect, theme.selected, viewport, radius=3.0) + draw_text(renderer, label, row_rect.x + 24.0, row_rect.y + 7.0, viewport, scale=0.54, color=theme.text) + if max_scroll: + track = Rect(self.rect.x + self.rect.width - self.scrollbar_width + 2.0, self.rect.y + 3.0, self.scrollbar_width - 5.0, self.rect.height - 6.0) + draw_rect(renderer, track, theme.input, viewport, radius=3.0) + thumb_height = max(22.0, track.height * visible_rows / max(visible_rows, len(self.rows))) + thumb_y = track.y + (track.height - thumb_height) * (self.scroll / max_scroll) + draw_rect(renderer, Rect(track.x, thumb_y, track.width, thumb_height), theme.accent, viewport, radius=3.0) diff --git a/nexora/nodes/entity/character_controller_2d.py b/nexora/nodes/entity/character_controller_2d.py index 7b2cd9b..b0e2562 100644 --- a/nexora/nodes/entity/character_controller_2d.py +++ b/nexora/nodes/entity/character_controller_2d.py @@ -569,8 +569,8 @@ def _play_sound(self, sound: Any) -> None: sound = self.audio.load(sound) if sound is None: return - from nexora.audio import AudioChannel, AudioSource - source = AudioSource(sound, channel=AudioChannel.SFX, position=self.world_position) + from nexora.audio import AudioSource + source = AudioSource(sound, bus="SFX", position=self.world_position) player = getattr(self.audio, "player", None) if player is not None and callable(getattr(player, "play", None)): player.play(source) diff --git a/nexora/nodes/ui/dialogs/dialogue_box.py b/nexora/nodes/ui/dialogs/dialogue_box.py index 6b7d035..54cc0e3 100644 --- a/nexora/nodes/ui/dialogs/dialogue_box.py +++ b/nexora/nodes/ui/dialogs/dialogue_box.py @@ -7,7 +7,7 @@ import sdl3 -from nexora.audio import AudioChannel, AudioSource +from nexora.audio import AudioSource from nexora.nodes.effects import Light2D from nexora.nodes.ui.containers.panel import Panel from nexora.nodes.ui.controls.button import Button @@ -803,7 +803,7 @@ def _play_typing_tick(self) -> None: if self._typing_source is None: self._typing_source = AudioSource( self._typing_sound, - channel=AudioChannel.SFX, + bus="SFX", volume=self.typing_sound_volume, loop=False, ) diff --git a/nexora/nodes/world/tilemap_node.py b/nexora/nodes/world/tilemap_node.py index 7468fe3..354a7df 100644 --- a/nexora/nodes/world/tilemap_node.py +++ b/nexora/nodes/world/tilemap_node.py @@ -57,8 +57,8 @@ def __init__( self.tilemap: TileMap | None = None self.tileset: TileSet | None = None self.texture = None - # Project-assets-relative path to a .tilemap.net file. Inline maps - # from older .nxscene files keep this as None for compatibility. + # Project-assets-relative path to a .ntmap file. Inline maps in + # scenes keep this as None. self.tilemap_asset: str | None = None # Shared navigation state. Every TileNavigation created from this @@ -191,7 +191,7 @@ def trigger_layers(self): return self.layers_with_role("trigger") def load_tilemap_asset(self, asset_path: str, assets) -> None: - """Load and bind a ``.tilemap.net`` asset from the project assets.""" + """Load and bind an ``.ntmap`` asset from the project assets.""" if assets is None: raise RuntimeError("Loading a TileMap asset requires an AssetManager.") asset = TileMapAsset.load(assets.resolve(asset_path)) diff --git a/nexora/settings/audio.py b/nexora/settings/audio.py index 843e662..9d6998a 100644 --- a/nexora/settings/audio.py +++ b/nexora/settings/audio.py @@ -77,8 +77,7 @@ class AudioSettings: Example: - audio.volume.master = 0.8 - + audio.buses.master.volume = 0.8 audio.buses.music.volume = 0.5 audio.buses.music.muted = False """ @@ -113,13 +112,6 @@ def __init__( Any, ] = {} - self.volume = ( - AudioSection( - self, - "volume", - ) - ) - self.buses = ( AudioSection( self, diff --git a/nexora/settings/audio_store.py b/nexora/settings/audio_store.py index 3b1f8a0..66846d5 100644 --- a/nexora/settings/audio_store.py +++ b/nexora/settings/audio_store.py @@ -5,601 +5,133 @@ import tomllib from typing import Any, Mapping -from nexora.settings.layered_store import ( - LayeredSettingsStore, -) +from nexora.settings.layered_store import LayeredSettingsStore -class AudioSettingsStore( - LayeredSettingsStore[ - dict[str, Any] - ] -): - """ - Layered storage for user-facing audio settings. - - Audio settings intentionally contain only: - - channel volumes - bus volumes - bus mute states - - Technical audio configuration such as frequency, - channels and buffer sizes belongs to engine.toml. - """ +class AudioSettingsStore(LayeredSettingsStore[dict[str, Any]]): + """Layered storage for the dynamic audio bus graph.""" FILE_NAME = "audio.toml" - USER_HEADER = ( "# Nexora user audio settings\n" "# Only changed audio settings are stored here.\n" ) - CHANNELS = ( - "master", - "music", - "sfx", - "ambient", - "voice", - ) - - # ========================================================== - # LAYERED STORE - # ========================================================== - - def _empty( - self, - ) -> dict[str, Any]: + def _empty(self) -> dict[str, Any]: return {} - def _read_layer( - self, - path: Path, - *, - required: bool, - ) -> dict[str, Any]: + def _read_layer(self, path: Path, *, required: bool) -> dict[str, Any]: if not path.is_file(): if required: - raise FileNotFoundError( - "Audio settings file not found: " - f"{path}" - ) - + raise FileNotFoundError(f"Audio settings file not found: {path}") return {} - with path.open( - "rb" - ) as file: - data = tomllib.load( - file - ) - - if not isinstance( - data, - dict, - ): - raise ValueError( - f"Invalid audio settings file: {path}" - ) - - self._validate_layer( - data, - path, - ) - - return deepcopy( - data - ) - - def _merge_layer( - self, - target: dict[str, Any], - source: dict[str, Any], - ) -> None: - self._deep_merge( - target, - source, - ) - - def _validate_user( - self, - data: dict[str, Any], - ) -> None: - self._validate_layer( - data, - self.user_path - or Path( - self.FILE_NAME - ), - ) - - def _validate_complete( - self, - data: dict[str, Any], - ) -> None: - volume = data.get( - "volume" - ) - - if not isinstance( - volume, - dict, - ): - raise ValueError( - "Missing audio section: volume" - ) - - missing_channels = ( - set( - self.CHANNELS - ) - - set( - volume - ) - ) - - if missing_channels: - raise ValueError( - "Missing audio volume setting(s): " - + ", ".join( - sorted( - missing_channels - ) - ) - ) - - buses = data.get( - "buses" - ) + with path.open("rb") as file: + data = tomllib.load(file) - if not isinstance( - buses, - dict, - ): - raise ValueError( - "Missing audio section: buses" - ) + if not isinstance(data, dict): + raise ValueError(f"Invalid audio settings file: {path}") - missing_buses = ( - set( - self.CHANNELS - ) - - set( - buses - ) - ) + self._validate_layer(data, path) + return deepcopy(data) - if missing_buses: - raise ValueError( - "Missing audio bus setting(s): " - + ", ".join( - sorted( - missing_buses - ) - ) - ) - - for bus_name in self.CHANNELS: - bus = buses.get( - bus_name - ) - - if not isinstance( - bus, - dict, - ): - raise ValueError( - f"Missing audio bus: {bus_name}" - ) - - missing = ( - { - "volume", - "muted", - } - - set( - bus - ) - ) + def _merge_layer(self, target: dict[str, Any], source: dict[str, Any]) -> None: + self._deep_merge(target, source) - if missing: - raise ValueError( - f"Missing setting(s) in " - f"[buses.{bus_name}]: " - + ", ".join( - sorted( - missing - ) - ) - ) + def _validate_user(self, data: dict[str, Any]) -> None: + self._validate_layer(data, self.user_path or Path(self.FILE_NAME)) - def _encode_user( - self, - data: dict[str, Any], - ) -> str: - return self._encode_toml( - data - ) + def _validate_complete(self, data: dict[str, Any]) -> None: + buses = data.get("buses") + if not isinstance(buses, dict): + raise ValueError("Missing audio section: buses") + if "master" not in {str(name).casefold() for name in buses}: + raise ValueError("Missing audio bus: master") + for bus_name, bus in buses.items(): + self._validate_bus(str(bus_name), bus, Path(self.FILE_NAME)) - # ========================================================== - # MERGE - # ========================================================== + def _encode_user(self, data: dict[str, Any]) -> str: + return self._encode_toml(data) @classmethod - def _deep_merge( - cls, - target: dict[str, Any], - source: Mapping[str, Any], - ) -> None: + def _deep_merge(cls, target: dict[str, Any], source: Mapping[str, Any]) -> None: for key, value in source.items(): - current = target.get( - key - ) - - if ( - isinstance( - current, - dict, - ) - and isinstance( - value, - Mapping, - ) - ): - cls._deep_merge( - current, - value, - ) - + current = target.get(key) + if isinstance(current, dict) and isinstance(value, Mapping): + cls._deep_merge(current, value) else: - target[ - key - ] = deepcopy( - value - ) - - # ========================================================== - # VALIDATION - # ========================================================== + target[key] = deepcopy(value) @classmethod - def _validate_layer( - cls, - data: Mapping[str, Any], - path: Path, - ) -> None: - allowed = { - "volume", - "buses", - } - - unknown = ( - set( - data - ) - - allowed - ) - + def _validate_layer(cls, data: Mapping[str, Any], path: Path) -> None: + unknown = set(data) - {"buses"} if unknown: raise ValueError( - "Unknown audio section(s) in " - f"{path}: " - + ", ".join( - sorted( - unknown - ) - ) - ) - - # ------------------------------------------------------ - # Channel volumes - # ------------------------------------------------------ - - volume = data.get( - "volume" - ) - - if volume is not None: - if not isinstance( - volume, - dict, - ): - raise ValueError( - f"[volume] in {path} " - "must be a TOML table." - ) - - unknown_channels = ( - set( - volume - ) - - set( - cls.CHANNELS - ) + f"Unknown audio section(s) in {path}: " + + ", ".join(sorted(unknown)) ) - if unknown_channels: - raise ValueError( - "Unknown audio channel(s) in " - f"{path}: " - + ", ".join( - sorted( - unknown_channels - ) - ) - ) - - for name, value in volume.items(): - cls._volume( - value, - f"volume.{name}", - path, - ) - - # ------------------------------------------------------ - # Buses - # ------------------------------------------------------ - - buses = data.get( - "buses" - ) - - if buses is not None: - if not isinstance( - buses, - dict, - ): - raise ValueError( - f"[buses] in {path} " - "must be a TOML table." - ) - - unknown_buses = ( - set( - buses - ) - - set( - cls.CHANNELS - ) - ) - - if unknown_buses: - raise ValueError( - "Unknown audio bus(es) in " - f"{path}: " - + ", ".join( - sorted( - unknown_buses - ) - ) - ) - - for bus_name, bus in buses.items(): - cls._validate_bus( - bus_name, - bus, - path, - ) + buses = data.get("buses") + if buses is None: + return + if not isinstance(buses, dict): + raise ValueError(f"[buses] in {path} must be a TOML table.") + for bus_name, bus in buses.items(): + cls._validate_bus(str(bus_name), bus, path) @classmethod - def _validate_bus( - cls, - bus_name: str, - bus: object, - path: Path, - ) -> None: - if not isinstance( - bus, - dict, - ): - raise ValueError( - f"[buses.{bus_name}] in {path} " - "must be a TOML table." - ) - - allowed = { - "volume", - "muted", - } - - unknown = ( - set( - bus - ) - - allowed - ) + def _validate_bus(cls, bus_name: str, bus: object, path: Path) -> None: + if not isinstance(bus, dict): + raise ValueError(f"[buses.{bus_name}] in {path} must be a TOML table.") + unknown = set(bus) - {"volume", "muted", "parent"} if unknown: raise ValueError( - "Unknown setting(s) in " - f"[buses.{bus_name}] in {path}: " - + ", ".join( - sorted( - unknown - ) - ) + f"Unknown setting(s) in [buses.{bus_name}] in {path}: " + + ", ".join(sorted(unknown)) ) if "volume" in bus: - cls._volume( - bus[ - "volume" - ], - f"buses.{bus_name}.volume", - path, - ) - - if "muted" in bus: - if not isinstance( - bus[ - "muted" - ], - bool, - ): - raise ValueError( - f"buses.{bus_name}.muted " - f"in {path} must be a boolean." - ) + cls._volume(bus["volume"], f"buses.{bus_name}.volume", path) + if "muted" in bus and not isinstance(bus["muted"], bool): + raise ValueError(f"buses.{bus_name}.muted in {path} must be a boolean.") + if "parent" in bus and not isinstance(bus["parent"], str): + raise ValueError(f"buses.{bus_name}.parent in {path} must be a string.") @staticmethod - def _volume( - value: object, - name: str, - path: Path, - ) -> None: - if ( - isinstance( - value, - bool, - ) - or not isinstance( - value, - ( - int, - float, - ), - ) - ): - raise ValueError( - f"{name} in {path} must be a number." - ) - - value = float( - value - ) - - if not 0.0 <= value <= 1.0: - raise ValueError( - f"{name} in {path} " - "must be between 0.0 and 1.0." - ) - - # ========================================================== - # TOML ENCODING - # ========================================================== + def _volume(value: object, name: str, path: Path) -> None: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError(f"{name} in {path} must be a number.") + value = float(value) + if value < 0.0: + raise ValueError(f"{name} in {path} must not be negative.") @classmethod - def _encode_toml( - cls, - data: Mapping[str, Any], - ) -> str: + def _encode_toml(cls, data: Mapping[str, Any]) -> str: lines = [ "# Nexora user audio settings", "# Only changed audio settings are stored here.", "", ] - - # ------------------------------------------------------ - # Volume - # ------------------------------------------------------ - - volume = data.get( - "volume" - ) - - if ( - isinstance( - volume, - Mapping, - ) - and volume - ): - lines.append( - "[volume]" - ) - - for channel in cls.CHANNELS: - if channel not in volume: - continue - - lines.append( - f"{channel} = " - f"{cls._encode_value(volume[channel])}" - ) - - lines.append( - "" - ) - - # ------------------------------------------------------ - # Buses - # ------------------------------------------------------ - - buses = data.get( - "buses" - ) - - if isinstance( - buses, - Mapping, - ): - for bus_name in cls.CHANNELS: - bus = buses.get( - bus_name - ) - - if ( - not isinstance( - bus, - Mapping, - ) - or not bus - ): + buses = data.get("buses") + if isinstance(buses, Mapping): + for bus_name, bus in buses.items(): + if not isinstance(bus, Mapping) or not bus: continue - - lines.append( - f"[buses.{bus_name}]" - ) - - if "volume" in bus: - lines.append( - "volume = " - f"{cls._encode_value(bus['volume'])}" - ) - - if "muted" in bus: - lines.append( - "muted = " - f"{cls._encode_value(bus['muted'])}" - ) - - lines.append( - "" - ) - - return ( - "\n".join( - lines - ).rstrip() - + "\n" - ) + lines.append(f"[buses.{bus_name}]") + for key in ("parent", "volume", "muted"): + if key in bus: + lines.append(f"{key} = {cls._encode_value(bus[key])}") + lines.append("") + return "\n".join(lines).rstrip() + "\n" @staticmethod - def _encode_value( - value: Any, - ) -> str: - if isinstance( - value, - bool, - ): - return ( - "true" - if value - else "false" - ) - - if isinstance( - value, - ( - int, - float, - ), - ): - return repr( - value - ) - - raise TypeError( - "Unsupported TOML value: " - f"{type(value).__name__}" - ) \ No newline at end of file + def _encode_value(value: Any) -> str: + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (int, float)): + return repr(value) + if isinstance(value, str): + escaped = value.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + raise TypeError(f"Unsupported TOML value: {type(value).__name__}") diff --git a/nexora/tilemap/asset.py b/nexora/tilemap/asset.py index eeaffd5..f4f7738 100644 --- a/nexora/tilemap/asset.py +++ b/nexora/tilemap/asset.py @@ -2,7 +2,7 @@ """Portable TileMap authoring assets. -The ``.tilemap.net`` format is deliberately independent from ``.nxscene``. +The ``.ntmap`` format is deliberately independent from ``.nxscene``. It stores the tileset layout and the painted map layers, while a scene only needs to reference the asset from its :class:`TileMapNode`. """ @@ -17,7 +17,7 @@ TILEMAP_ASSET_FORMAT = "nexora_tilemap" TILEMAP_ASSET_VERSION = 1 -TILEMAP_ASSET_SUFFIX = ".tilemap.net" +TILEMAP_ASSET_SUFFIX = ".ntmap" class TileMapAsset: diff --git a/pyproject.toml b/pyproject.toml index 3b56cd3..8960a2b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ dependencies = [ "PySDL3>=0.9.12b1", "psutil>=7.0", "nvidia-ml-py>=13.0", + "numpy>=2.3", ] diff --git a/tests/test_audio_system.py b/tests/test_audio_system.py index 0b0a6ba..289fc38 100644 --- a/tests/test_audio_system.py +++ b/tests/test_audio_system.py @@ -5,15 +5,27 @@ import struct import wave - +import numpy as np import pytest pytestmark = pytest.mark.audio from nexora.audio import ( AudioBuffer, AudioBus, - AudioChannel, AudioMixer, + GainEffect, + LimiterEffect, + LowPassFilterEffect, + HighPassFilterEffect, + ParametricEQEffect, + CompressorEffect, + DelayEffect, + ReverbEffect, + DistortionEffect, + NoiseGateEffect, + StereoWidthEffect, + AudioPreset, + AudioPresetRegistry, AudioPlayer, AudioSource, AudioSourceState, @@ -314,121 +326,72 @@ def fake_load(self, load_path): # ============================================================ -# AudioMixer +# AudioMixer / dynamic bus graph # ============================================================ def test_audio_mixer_defaults(): mixer = AudioMixer() - - for channel in AudioChannel: - assert mixer.get_volume(channel) == 1.0 + assert mixer.master.name == "Master" + assert mixer.master.volume == 1.0 + assert mixer.master.parent is None -def test_audio_mixer_set_volume(): +def test_audio_mixer_create_hierarchical_bus(): mixer = AudioMixer() + sfx = mixer.get_bus("SFX") + sfx.volume = 0.8 + weapons = mixer.create_bus("Weapons", parent="SFX", volume=0.5) + mixer.master.volume = 0.5 - mixer.set_volume( - AudioChannel.MUSIC, - 0.5, - ) + assert weapons.parent is sfx + assert weapons.effective_volume == pytest.approx(0.2) - assert mixer.get_volume( - AudioChannel.MUSIC - ) == 0.5 - -def test_audio_mixer_volume_lower_bound(): +def test_audio_mixer_bus_names_are_case_insensitive(): mixer = AudioMixer() + bus = mixer.get_bus("Music") + assert mixer.get_bus("music") is bus + assert mixer.has_bus("MUSIC") - mixer.set_volume( - AudioChannel.SFX, - 0.0, - ) - assert mixer.get_volume( - AudioChannel.SFX - ) == 0.0 - - -def test_audio_mixer_volume_upper_bound(): +def test_audio_mixer_rejects_duplicate_bus(): mixer = AudioMixer() - - mixer.set_volume( - AudioChannel.SFX, - 1.0, - ) - - assert mixer.get_volume( - AudioChannel.SFX - ) == 1.0 + with pytest.raises(ValueError): + mixer.create_bus("sfx") -@pytest.mark.parametrize( - "value", - [-0.1, 1.1, 2.0], -) -def test_audio_mixer_invalid_volume(value): +def test_audio_mixer_rejects_bus_cycles(): mixer = AudioMixer() - + a = mixer.create_bus("A") + mixer.create_bus("B", parent="A") with pytest.raises(ValueError): - mixer.set_volume( - AudioChannel.SFX, - value, - ) + mixer.set_bus_parent("A", "B") -def test_audio_mixer_effective_volume(): +def test_audio_mixer_remove_bus_reparents_children(): mixer = AudioMixer() + child = mixer.create_bus("Weapons", parent="SFX") + mixer.remove_bus("SFX", reparent_children_to="Master") + assert child.parent is mixer.master - mixer.set_volume( - AudioChannel.MASTER, - 0.5, - ) - - mixer.set_volume( - AudioChannel.MUSIC, - 0.8, - ) - assert math.isclose( - mixer.get_effective_volume( - AudioChannel.MUSIC - ), - 0.4, - ) - - -def test_audio_mixer_master_effective_volume(): +def test_audio_mixer_master_cannot_be_removed(): mixer = AudioMixer() - - mixer.set_volume( - AudioChannel.MASTER, - 0.25, - ) - - assert mixer.get_effective_volume( - AudioChannel.MASTER - ) == 0.25 + with pytest.raises(ValueError): + mixer.remove_bus("Master") def test_audio_mixer_reset(): mixer = AudioMixer() - - mixer.set_volume( - AudioChannel.MASTER, - 0.2, - ) - - mixer.set_volume( - AudioChannel.MUSIC, - 0.4, - ) - + bus = mixer.get_bus("Music") + bus.volume = 0.4 + bus.muted = True + mixer.master.volume = 0.2 mixer.reset() - - for channel in AudioChannel: - assert mixer.get_volume(channel) == 1.0 + assert mixer.master.volume == 1.0 + assert bus.volume == 1.0 + assert not bus.muted # ============================================================ @@ -585,29 +548,13 @@ def test_audio_source_loop(tmp_path): def test_audio_source_effective_volume(tmp_path): sound = create_sound(tmp_path) - mixer = AudioMixer() + mixer.get_bus("SFX").volume = 0.8 + mixer.master.volume = 0.5 - mixer.set_volume( - AudioChannel.MASTER, - 0.5, - ) - - mixer.set_volume( - AudioChannel.SFX, - 0.8, - ) + source = AudioSource(sound, bus="SFX", volume=0.5) - source = AudioSource( - sound, - channel=AudioChannel.SFX, - volume=0.5, - ) - - assert math.isclose( - source.get_effective_volume(mixer), - 0.2, - ) + assert math.isclose(source.get_effective_volume(mixer), 0.2) # ============================================================ @@ -811,6 +758,7 @@ def test_audio_player_source_finishes(tmp_path): assert source.stopped assert source.position == sound.buffer.sample_count + assert player.sources == () player.mix(1) @@ -1043,38 +991,18 @@ def test_audio_system_initial_state(): assert audio.music is not None -def test_audio_system_volume(): +def test_audio_system_bus_volume(): audio = AudioSystem() + audio.set_bus_volume("Music", 0.4) + assert audio.get_bus_volume("Music") == 0.4 - audio.set_volume( - AudioChannel.MUSIC, - 0.4, - ) - - assert audio.get_volume( - AudioChannel.MUSIC - ) == 0.4 - -def test_audio_system_effective_volume(): +def test_audio_system_dynamic_hierarchical_bus(): audio = AudioSystem() - - audio.set_volume( - AudioChannel.MASTER, - 0.5, - ) - - audio.set_volume( - AudioChannel.SFX, - 0.8, - ) - - assert math.isclose( - audio.get_effective_volume( - AudioChannel.SFX - ), - 0.4, - ) + audio.set_bus_volume("Master", 0.5) + audio.set_bus_volume("SFX", 0.8) + weapons = audio.create_bus("Weapons", parent="SFX", volume=0.5) + assert weapons.effective_volume == pytest.approx(0.2) def test_audio_system_initialize(): @@ -1586,7 +1514,10 @@ def test_sound_pcm_cache_contains_decoded_samples(): samples = sound.pcm - assert isinstance(samples, list) + assert isinstance(samples, np.ndarray) + assert samples.dtype == np.float32 + assert samples.flags.c_contiguous + assert not samples.flags.writeable assert len(samples) == 100 @@ -2920,74 +2851,74 @@ def test_audio_mixer_has_master_bus(): def test_audio_mixer_buses_contains_master(): mixer = AudioMixer() - assert mixer.buses == ( - mixer.master, + assert tuple(bus.name for bus in mixer.buses) == ( + "Master", + "Music", + "SFX", + "Ambient", + "Voice", ) def test_audio_mixer_add_bus(): mixer = AudioMixer() - music = AudioBus( - "Music", + weapons = AudioBus( + "Weapons", volume=0.75, ) - mixer.add_bus(music) + mixer.add_bus(weapons) - assert mixer.get_bus("Music") is music - assert mixer.buses == ( - mixer.master, - music, - ) + assert mixer.get_bus("Weapons") is weapons + assert mixer.buses[-1] is weapons def test_audio_mixer_add_multiple_buses(): mixer = AudioMixer() - music = AudioBus("Music") - sfx = AudioBus("SFX") - voice = AudioBus("Voice") + weapons = AudioBus("Weapons") + footsteps = AudioBus("Footsteps") + radio = AudioBus("Radio") - mixer.add_bus(music) - mixer.add_bus(sfx) - mixer.add_bus(voice) + mixer.add_bus(weapons) + mixer.add_bus(footsteps) + mixer.add_bus(radio) - assert mixer.buses == ( - mixer.master, - music, - sfx, - voice, + assert mixer.buses[-3:] == ( + weapons, + footsteps, + radio, ) def test_audio_mixer_get_bus(): mixer = AudioMixer() - music = AudioBus("Music") + weapons = AudioBus("Weapons") - mixer.add_bus(music) + mixer.add_bus(weapons) - assert mixer.get_bus("Music") is music + assert mixer.get_bus("Weapons") is weapons def test_audio_mixer_get_unknown_bus(): mixer = AudioMixer() with pytest.raises(KeyError): - mixer.get_bus("Music") + mixer.get_bus("Missing") def test_audio_mixer_duplicate_bus(): mixer = AudioMixer() mixer.add_bus( - AudioBus("Music") + AudioBus("Weapons") ) with pytest.raises(ValueError): mixer.add_bus( - AudioBus("Music") + AudioBus("Weapons") ) @@ -3004,13 +2935,13 @@ def test_audio_mixer_remove_bus(): mixer = AudioMixer() mixer.add_bus( - AudioBus("Music") + AudioBus("Weapons") ) - mixer.remove_bus("Music") + mixer.remove_bus("Weapons") with pytest.raises(KeyError): - mixer.get_bus("Music") + mixer.get_bus("Weapons") def test_audio_mixer_cannot_remove_master(): @@ -3024,7 +2955,7 @@ def test_audio_mixer_remove_unknown_bus(): mixer = AudioMixer() with pytest.raises(KeyError): - mixer.remove_bus("Music") + mixer.remove_bus("Missing") # ============================================================ @@ -3037,7 +2968,7 @@ def test_audio_source_default_bus(): source = AudioSource(sound) - assert source.bus is None + assert source.bus == "Master" def test_audio_source_bus_constructor(): @@ -3080,19 +3011,13 @@ def test_audio_source_bus_can_be_cleared(): def test_audio_source_effective_volume_includes_bus(): sound = make_test_sound() mixer = AudioMixer() + mixer.master.volume = 0.5 + bus = mixer.get_bus("Music") + bus.volume = 0.5 - bus = AudioBus("Music", volume=0.5) - - source = AudioSource( - sound, - channel=AudioChannel.SFX, - volume=0.8, - bus=bus, - ) - - mixer.set_volume(AudioChannel.SFX, 0.5) + source = AudioSource(sound, volume=0.8, bus=bus) - assert source.get_effective_volume(mixer) == 0.2 + assert source.get_effective_volume(mixer) == pytest.approx(0.2) def test_audio_source_effective_volume_respects_bus_mute(): @@ -3364,6 +3289,28 @@ def test_audio_player_stops_source_when_sound_finishes(): assert source.position == sound.buffer.sample_count + + +def test_audio_player_stop_all_releases_stopped_sources(): + sound = make_test_sound(value=16384, frames=48000) + source_a = AudioSource(sound) + source_b = AudioSource(sound) + + device = AudioDevice() + mixer = AudioMixer() + player = AudioPlayer(device, mixer) + + player.play(source_a) + player.play(source_b) + assert len(player.sources) == 2 + + player.stop_all() + + assert source_a.stopped + assert source_b.stopped + assert player.sources == () + + def test_audio_player_loops_source_when_sound_finishes(): sound = make_test_sequence_sound() source = AudioSource( @@ -3645,4 +3592,619 @@ def test_audio_player_spatial_center_source(): left, right = struct.unpack("<2f", output) assert left == pytest.approx(right) - assert left > 0.0 \ No newline at end of file + assert left > 0.0 + +# ============================================================ +# Bus DSP routing +# ============================================================ + + +def test_audio_bus_effect_stack_order(): + bus = AudioBus("Test") + first = GainEffect(0.5) + second = GainEffect(0.25) + + bus.add_effect(first) + bus.add_effect(second) + + assert bus.effects == (first, second) + + bus.remove_effect(first) + assert bus.effects == (second,) + + +def test_audio_player_processes_child_bus_before_parent(tmp_path): + player = create_audio_player() + player.mixer.create_bus("Weapons", parent="SFX") + player.mixer.get_bus("Weapons").add_effect(GainEffect(0.5)) + player.mixer.get_bus("SFX").add_effect(GainEffect(0.5)) + + sound = create_sound( + tmp_path, + channels=1, + frames=4, + value=16384, + ) + source = AudioSource(sound, bus="Weapons") + player.play(source) + + data = player.mix(1) + left, right = struct.unpack("<2f", data) + expected = (16384 / 32768) * 0.5 * 0.5 + assert math.isclose(left, expected, rel_tol=1e-5) + assert math.isclose(right, expected, rel_tol=1e-5) + + +def test_audio_bus_volume_applied_once_per_hierarchy_level(tmp_path): + player = create_audio_player() + weapons = player.mixer.create_bus("Weapons", parent="SFX", volume=0.5) + player.mixer.get_bus("SFX").volume = 0.5 + player.mixer.master.volume = 0.5 + + sound = create_sound( + tmp_path, + channels=1, + frames=4, + value=16384, + ) + source = AudioSource(sound, bus=weapons) + player.play(source) + + data = player.mix(1) + left, right = struct.unpack("<2f", data) + expected = (16384 / 32768) * 0.5 * 0.5 * 0.5 + assert math.isclose(left, expected, rel_tol=1e-5) + assert math.isclose(right, expected, rel_tol=1e-5) + + +def test_audio_bus_limiter_runs_before_parent(tmp_path): + player = create_audio_player() + hot = player.mixer.create_bus("Hot", parent="Master") + hot.add_effect(LimiterEffect(0.25)) + + sound = create_sound( + tmp_path, + channels=1, + frames=4, + value=32767, + ) + player.play(AudioSource(sound, bus="Hot")) + + data = player.mix(1) + left, right = struct.unpack("<2f", data) + assert math.isclose(left, 0.25, rel_tol=1e-5) + assert math.isclose(right, 0.25, rel_tol=1e-5) + +# ============================================================ +# Mixer controls: pan / solo / metering +# ============================================================ + + +def test_audio_bus_pan_validation(): + bus = AudioBus("Music") + bus.pan = -1.0 + assert bus.pan == -1.0 + bus.pan = 1.0 + assert bus.pan == 1.0 + + with pytest.raises(ValueError): + bus.pan = -1.01 + with pytest.raises(ValueError): + bus.pan = 1.01 + + +def test_audio_player_applies_bus_pan(tmp_path): + player = create_audio_player() + player.mixer.get_bus("SFX").pan = 1.0 + + sound = create_sound( + tmp_path, + channels=1, + frames=4, + value=16384, + ) + player.play(AudioSource(sound, bus="SFX")) + + data = player.mix(1) + left, right = struct.unpack("<2f", data) + assert left == pytest.approx(0.0, abs=1e-7) + assert right == pytest.approx(16384 / 32768, rel=1e-5) + + +def test_audio_mixer_solo_keeps_selected_branch_audible(tmp_path): + player = create_audio_player() + player.mixer.get_bus("SFX").solo = True + + sound = create_sound( + tmp_path, + channels=1, + frames=4, + value=8192, + ) + player.play(AudioSource(sound, bus="SFX")) + player.play(AudioSource(sound, bus="Music")) + + data = player.mix(1) + left, right = struct.unpack("<2f", data) + expected = 8192 / 32768 + assert left == pytest.approx(expected, rel=1e-5) + assert right == pytest.approx(expected, rel=1e-5) + + +def test_audio_mixer_solo_parent_keeps_child_audible(): + mixer = AudioMixer() + weapons = mixer.create_bus("Weapons", parent="SFX") + mixer.get_bus("SFX").solo = True + + assert mixer.is_bus_audible(weapons) is True + assert mixer.is_bus_audible(mixer.get_bus("SFX")) is True + assert mixer.is_bus_audible(mixer.master) is True + assert mixer.is_bus_audible(mixer.get_bus("Music")) is False + + +def test_audio_bus_peak_and_rms_metering(tmp_path): + player = create_audio_player() + sound = create_sound( + tmp_path, + channels=1, + frames=4, + value=16384, + ) + player.play(AudioSource(sound, bus="SFX")) + + player.mix(2) + bus = player.mixer.get_bus("SFX") + expected = 16384 / 32768 + + assert bus.peak[0] == pytest.approx(expected, rel=1e-5) + assert bus.peak[1] == pytest.approx(expected, rel=1e-5) + assert bus.rms[0] == pytest.approx(expected, rel=1e-5) + assert bus.rms[1] == pytest.approx(expected, rel=1e-5) + + bus.mute() + player.mix(1) + assert bus.peak == (0.0, 0.0) + assert bus.rms == (0.0, 0.0) + + +def test_audio_bus_ids_survive_rename(): + mixer = AudioMixer() + bus = mixer.create_bus("Weapons", parent="SFX") + bus_id = bus.id + + mixer.rename_bus("Weapons", "Guns") + + assert mixer.get_bus("Guns") is bus + assert mixer.get_bus_by_id(bus_id) is bus + assert bus.id == bus_id + with pytest.raises(KeyError): + mixer.get_bus("Weapons") + + +def test_audio_send_rejects_feedback_cycle(): + mixer = AudioMixer() + mixer.create_bus("Weapons", parent="SFX") + mixer.create_bus("Reverb", parent="Master") + mixer.add_send("Weapons", "Reverb") + + with pytest.raises(ValueError, match="feedback cycles"): + mixer.add_send("Reverb", "Weapons") + + +def test_audio_player_post_fader_send(tmp_path): + player = create_audio_player() + player.mixer.get_bus("SFX").volume = 0.5 + player.mixer.create_bus("Reverb", parent="Master", volume=0.5) + player.mixer.add_send("SFX", "Reverb", amount=1.0, pre_fader=False) + sound = create_sound(tmp_path, channels=1, frames=4, value=16384) + player.play(AudioSource(sound, bus="SFX")) + + left, right = struct.unpack("<2f", player.mix(1)) + # source=.5; direct=.25; post send=.25 * return .5=.125 + assert left == pytest.approx(0.375, rel=1e-5) + assert right == pytest.approx(0.375, rel=1e-5) + + +def test_audio_player_pre_fader_send(tmp_path): + player = create_audio_player() + player.mixer.get_bus("SFX").volume = 0.5 + player.mixer.create_bus("Reverb", parent="Master", volume=0.5) + player.mixer.add_send("SFX", "Reverb", amount=1.0, pre_fader=True) + sound = create_sound(tmp_path, channels=1, frames=4, value=16384) + player.play(AudioSource(sound, bus="SFX")) + + left, right = struct.unpack("<2f", player.mix(1)) + # source=.5; direct=.25; pre send=.5 * return .5=.25 + assert left == pytest.approx(0.5, rel=1e-5) + assert right == pytest.approx(0.5, rel=1e-5) + + +def test_audio_effect_wet_dry_and_bypass(tmp_path): + player = create_audio_player() + effect = GainEffect(0.0, wet=0.5) + player.mixer.get_bus("SFX").add_effect(effect) + + # One frame keeps the two assertions isolated: after each mix the + # one-shot source is naturally finished and pruned. Using a longer sound + # here would intentionally overlap both sources on the second assertion, + # because AudioPlayer supports additive polyphonic playback. + sound = create_sound(tmp_path, channels=1, frames=1, value=16384) + player.play(AudioSource(sound, bus="SFX")) + + left, _ = struct.unpack("<2f", player.mix(1)) + assert left == pytest.approx(0.25, rel=1e-5) + + effect.bypassed = True + player.play(AudioSource(sound, bus="SFX")) + left, _ = struct.unpack("<2f", player.mix(1)) + assert left == pytest.approx(0.5, rel=1e-5) + + +def test_audio_bus_effect_chain_bypass(tmp_path): + player = create_audio_player() + bus = player.mixer.get_bus("SFX") + bus.add_effect(GainEffect(0.0)) + bus.effects_bypassed = True + sound = create_sound(tmp_path, channels=1, frames=4, value=16384) + player.play(AudioSource(sound, bus="SFX")) + + left, _ = struct.unpack("<2f", player.mix(1)) + assert left == pytest.approx(0.5, rel=1e-5) + + +def test_audio_effect_runtime_parameters(): + effect = GainEffect(1.0) + effect.set_parameter("gain", 0.25) + effect.set_parameter("wet", 0.75) + + assert effect.get_parameter("gain") == pytest.approx(0.25) + assert effect.get_parameter("wet") == pytest.approx(0.75) + with pytest.raises(KeyError): + effect.set_parameter("does_not_exist", 1) + + +def test_audio_master_headroom_and_limiter(tmp_path): + player = create_audio_player() + player.mixer.headroom_db = -6.0 + player.mixer.enable_master_limiter(0.2) + sound = create_sound(tmp_path, channels=1, frames=4, value=16384) + player.play(AudioSource(sound, bus="Master")) + + left, right = struct.unpack("<2f", player.mix(1)) + assert left == pytest.approx(0.2, rel=1e-5) + assert right == pytest.approx(0.2, rel=1e-5) + + +def test_audio_extended_metering_peak_hold_clip_and_dbfs(): + bus = AudioBus("Test") + block = np.array([[0.5, -1.1], [-0.25, 0.25]], dtype=np.float32) + bus.update_meter(block) + + assert bus.peak == pytest.approx((0.5, 1.1)) + assert bus.peak_hold == pytest.approx((0.5, 1.1)) + assert bus.clipped is True + assert bus.peak_dbfs[0] == pytest.approx(20.0 * math.log10(0.5)) + + bus.update_meter(np.array([[0.1, 0.1]], dtype=np.float32)) + assert bus.peak_hold == pytest.approx((0.5, 1.1)) + bus.clear_peak_hold() + assert bus.peak_hold == (0.0, 0.0) + assert bus.clipped is False + + +def test_audio_solo_keeps_send_return_audible(): + mixer = AudioMixer() + mixer.create_bus("Weapons", parent="SFX") + reverb = mixer.create_bus("Reverb", parent="Master") + mixer.add_send("Weapons", "Reverb") + mixer.get_bus("Weapons").solo = True + + assert mixer.is_bus_audible(reverb) is True + assert mixer.is_bus_audible(mixer.get_bus("Music")) is False + + +def test_audio_mixer_snapshot_restores_topology_sends_and_effects(): + mixer = AudioMixer() + weapons = mixer.create_bus("Weapons", parent="SFX", volume=0.7, pan=-0.2) + weapons.add_effect(GainEffect(0.5, wet=0.4)) + mixer.create_bus("Reverb", parent="Master", volume=0.8) + mixer.add_send("Weapons", "Reverb", amount=0.25, pre_fader=True) + mixer.headroom_db = -3.0 + mixer.enable_master_limiter(0.9) + snapshot = mixer.create_snapshot() + + mixer.rename_bus("Weapons", "Changed") + mixer.get_bus("Changed").volume = 0.1 + mixer.headroom_db = 0.0 + mixer.restore_snapshot(snapshot) + + restored = mixer.get_bus("Weapons") + assert restored.id == weapons.id + assert restored.volume == pytest.approx(0.7) + assert restored.pan == pytest.approx(-0.2) + assert isinstance(restored.effects[0], GainEffect) + assert restored.effects[0].gain == pytest.approx(0.5) + assert restored.effects[0].wet == pytest.approx(0.4) + assert len(mixer.sends) == 1 + assert mixer.sends[0].amount == pytest.approx(0.25) + assert mixer.sends[0].pre_fader is True + assert mixer.headroom_db == pytest.approx(-3.0) + assert mixer.master_limiter is not None + assert mixer.master_limiter.threshold == pytest.approx(0.9) + + +# ============================================================ +# Built-in DSP effects +# ============================================================ + + +def test_lowpass_filter_attenuates_high_frequency_signal(): + effect = LowPassFilterEffect(cutoff_hz=500.0) + signal = np.ones((4096, 2), dtype=np.float32) + signal[1::2] *= -1.0 + before = float(np.sqrt(np.mean(signal * signal))) + effect.process(signal, sample_rate=48_000, channels=2) + after = float(np.sqrt(np.mean(signal * signal))) + assert after < before * 0.2 + + +def test_highpass_filter_rejects_dc_after_settling(): + effect = HighPassFilterEffect(cutoff_hz=200.0) + signal = np.ones((8192, 2), dtype=np.float32) + effect.process(signal, sample_rate=48_000, channels=2) + assert float(np.max(np.abs(signal[-1024:]))) < 0.01 + + +def test_parametric_eq_zero_db_is_neutral(): + effect = ParametricEQEffect(frequency_hz=1_000.0, gain_db=0.0, q=1.0) + source = np.linspace(-0.8, 0.8, 512, dtype=np.float32) + signal = np.column_stack((source, source)).astype(np.float32) + expected = signal.copy() + effect.process(signal, sample_rate=48_000, channels=2) + assert np.allclose(signal, expected, atol=1e-5) + + +def test_compressor_reduces_hot_signal(): + effect = CompressorEffect( + threshold_db=-12.0, + ratio=8.0, + attack_ms=0.0, + release_ms=100.0, + ) + signal = np.full((256, 2), 0.9, dtype=np.float32) + effect.process(signal, sample_rate=48_000, channels=2) + assert float(np.max(np.abs(signal))) < 0.5 + assert effect.gain_reduction_db < 0.0 + + +def test_delay_effect_keeps_state_between_blocks(): + effect = DelayEffect(delay_seconds=4 / 48_000, feedback=0.0, wet=1.0) + first = np.zeros((4, 2), dtype=np.float32) + first[0] = 1.0 + effect.process(first, sample_rate=48_000, channels=2) + assert np.allclose(first, 0.0) + + second = np.zeros((4, 2), dtype=np.float32) + effect.process(second, sample_rate=48_000, channels=2) + assert np.allclose(second[0], 1.0) + assert np.allclose(second[1:], 0.0) + + +def test_reverb_effect_produces_tail_after_impulse(): + effect = ReverbEffect(room_size=0.2, damping=0.2, decay=0.7, wet=1.0) + impulse = np.zeros((1, 2), dtype=np.float32) + impulse[0] = 1.0 + effect.process(impulse, sample_rate=48_000, channels=2) + assert np.allclose(impulse, 0.0) + + tail = np.zeros((3000, 2), dtype=np.float32) + effect.process(tail, sample_rate=48_000, channels=2) + assert float(np.max(np.abs(tail))) > 0.0 + + +def test_dsp_effect_state_roundtrip_through_snapshot(): + mixer = AudioMixer() + bus = mixer.get_bus("SFX") + bus.add_effect(LowPassFilterEffect(4500.0, q=0.8, wet=0.7)) + bus.add_effect(CompressorEffect(-18.0, 3.0, 5.0, 150.0, 2.0)) + bus.add_effect(DelayEffect(0.15, 0.4, wet=0.3)) + + snapshot = mixer.create_snapshot() + mixer.restore_snapshot(snapshot) + + effects = mixer.get_bus("SFX").effects + assert isinstance(effects[0], LowPassFilterEffect) + assert effects[0].cutoff_hz == pytest.approx(4500.0) + assert effects[0].wet == pytest.approx(0.7) + assert isinstance(effects[1], CompressorEffect) + assert effects[1].ratio == pytest.approx(3.0) + assert isinstance(effects[2], DelayEffect) + assert effects[2].feedback == pytest.approx(0.4) + + +def test_distortion_soft_saturation_and_hard_clip(): + soft = DistortionEffect(drive=4.0, mode="soft") + signal = np.array([[0.5, -0.5]], dtype=np.float32) + soft.process(signal, sample_rate=48_000, channels=2) + assert 0.5 < float(signal[0, 0]) < 1.0 + assert -1.0 < float(signal[0, 1]) < -0.5 + + hard = DistortionEffect(drive=4.0, mode="hard") + signal = np.array([[0.5, -0.5]], dtype=np.float32) + hard.process(signal, sample_rate=48_000, channels=2) + assert np.allclose(signal, [[1.0, -1.0]]) + + +def test_noise_gate_closes_below_threshold_and_opens_above_it(): + gate = NoiseGateEffect( + threshold_db=-20.0, + attack_ms=0.0, + hold_ms=0.0, + release_ms=0.0, + ) + quiet = np.full((8, 2), 0.01, dtype=np.float32) + gate.process(quiet, sample_rate=48_000, channels=2) + assert np.allclose(quiet, 0.0) + + loud = np.full((8, 2), 0.5, dtype=np.float32) + gate.process(loud, sample_rate=48_000, channels=2) + assert np.allclose(loud, 0.5) + assert gate.gain == pytest.approx(1.0) + + +def test_noise_gate_hold_keeps_gate_open_across_samples(): + gate = NoiseGateEffect( + threshold_db=-20.0, + attack_ms=0.0, + hold_ms=2.0, + release_ms=0.0, + ) + first = np.array([[0.5, 0.5]], dtype=np.float32) + gate.process(first, sample_rate=1_000, channels=2) + assert np.allclose(first, 0.5) + + # 2 ms at 1 kHz = two held samples after the trigger. + held = np.full((2, 2), 0.01, dtype=np.float32) + gate.process(held, sample_rate=1_000, channels=2) + assert np.allclose(held, 0.01) + + closed = np.full((1, 2), 0.01, dtype=np.float32) + gate.process(closed, sample_rate=1_000, channels=2) + assert np.allclose(closed, 0.0) + + +def test_stereo_width_zero_collapses_to_mono_and_one_is_neutral(): + source = np.array([[1.0, 0.0], [0.25, -0.25]], dtype=np.float32) + effect = StereoWidthEffect(width=0.0) + signal = source.copy() + effect.process(signal, sample_rate=48_000, channels=2) + assert np.allclose(signal[:, 0], signal[:, 1]) + + effect.width = 1.0 + signal = source.copy() + effect.process(signal, sample_rate=48_000, channels=2) + assert np.allclose(signal, source) + + +def test_new_dsp_effects_roundtrip_through_snapshot(): + mixer = AudioMixer() + bus = mixer.get_bus("SFX") + bus.add_effect(DistortionEffect(3.0, -2.0, "hard", wet=0.6)) + bus.add_effect(NoiseGateEffect(-35.0, 1.0, 20.0, 100.0)) + bus.add_effect(StereoWidthEffect(1.4, wet=0.8)) + + snapshot = mixer.create_snapshot() + mixer.restore_snapshot(snapshot) + effects = mixer.get_bus("SFX").effects + + assert isinstance(effects[0], DistortionEffect) + assert effects[0].drive == pytest.approx(3.0) + assert effects[0].output_gain_db == pytest.approx(-2.0) + assert effects[0].mode == "hard" + assert effects[0].wet == pytest.approx(0.6) + assert isinstance(effects[1], NoiseGateEffect) + assert effects[1].threshold_db == pytest.approx(-35.0) + assert effects[1].hold_ms == pytest.approx(20.0) + assert isinstance(effects[2], StereoWidthEffect) + assert effects[2].width == pytest.approx(1.4) + assert effects[2].wet == pytest.approx(0.8) + + + +# ============================================================ +# DSP preset system +# ============================================================ + + +def test_audio_preset_registry_contains_builtins(): + registry = AudioPresetRegistry() + assert registry.has("Radio") + assert registry.has("Telephone") + assert registry.has("Underwater") + assert registry.has("Cave") + assert registry.has("Hall") + assert registry.has("Distorted Speaker") + assert registry.has("Wide Music") + + +def test_audio_preset_apply_creates_fresh_effect_instances(): + registry = AudioPresetRegistry(include_builtins=False) + registry.register_effects( + "Test", + (LowPassFilterEffect(2500.0), DelayEffect(0.1, 0.2, wet=0.3)), + ) + first = AudioBus("First") + second = AudioBus("Second") + + first_effects = registry.apply("Test", first) + second_effects = registry.apply("Test", second) + + assert len(first.effects) == 2 + assert len(second.effects) == 2 + assert first_effects[0] is not second_effects[0] + assert first_effects[1] is not second_effects[1] + + +def test_audio_preset_apply_replace_and_append(): + registry = AudioPresetRegistry(include_builtins=False) + registry.register_effects("Gain", (GainEffect(0.5),)) + bus = AudioBus("Bus") + bus.add_effect(LimiterEffect(0.9)) + + registry.apply("Gain", bus, replace=False) + assert isinstance(bus.effects[0], LimiterEffect) + assert isinstance(bus.effects[1], GainEffect) + + registry.apply("Gain", bus, replace=True) + assert len(bus.effects) == 1 + assert isinstance(bus.effects[0], GainEffect) + + +def test_audio_preset_capture_bus_roundtrip(): + registry = AudioPresetRegistry(include_builtins=False) + bus = AudioBus("Voice") + bus.add_effect(HighPassFilterEffect(300.0, wet=0.8)) + bus.add_effect(CompressorEffect(-18.0, 4.0, 5.0, 100.0, 1.0)) + + preset = registry.capture_bus("Voice FX", bus) + target = AudioBus("Target") + registry.apply("Voice FX", target) + + assert preset.name == "Voice FX" + assert isinstance(target.effects[0], HighPassFilterEffect) + assert target.effects[0].cutoff_hz == pytest.approx(300.0) + assert target.effects[0].wet == pytest.approx(0.8) + assert isinstance(target.effects[1], CompressorEffect) + assert target.effects[1].ratio == pytest.approx(4.0) + + +def test_audio_preset_json_save_load(tmp_path): + registry = AudioPresetRegistry(include_builtins=False) + registry.register_effects( + "My Preset", + (DistortionEffect(2.5, -2.0, "soft", wet=0.4), StereoWidthEffect(1.2)), + description="Custom test preset", + ) + path = tmp_path / "my_preset.json" + registry.save("My Preset", path) + + loaded_registry = AudioPresetRegistry(include_builtins=False) + preset = loaded_registry.load(path) + + assert preset.name == "My Preset" + assert preset.description == "Custom test preset" + bus = AudioBus("Target") + loaded_registry.apply("My Preset", bus) + assert isinstance(bus.effects[0], DistortionEffect) + assert bus.effects[0].drive == pytest.approx(2.5) + assert bus.effects[0].wet == pytest.approx(0.4) + assert isinstance(bus.effects[1], StereoWidthEffect) + assert bus.effects[1].width == pytest.approx(1.2) + + +def test_audio_preset_registration_is_case_insensitive(): + registry = AudioPresetRegistry(include_builtins=False) + registry.register(AudioPreset.from_effects("Radio FX", (GainEffect(0.8),))) + assert registry.get("radio fx").name == "Radio FX" + with pytest.raises(ValueError): + registry.register(AudioPreset.from_effects("RADIO FX", (GainEffect(1.0),))) diff --git a/tests/test_debug_overlay_audio.py b/tests/test_debug_overlay_audio.py new file mode 100644 index 0000000..e701383 --- /dev/null +++ b/tests/test_debug_overlay_audio.py @@ -0,0 +1,147 @@ +from types import SimpleNamespace + +from nexora.debug.overlay import DebugOverlay + + +def _bus(name, *, parent=None, peak=(-12.0, -10.0), volume=1.0, pan=0.0, + muted=False, solo=False, clipped=False, effects=()): + return SimpleNamespace( + id=name.lower(), + name=name, + parent=parent, + peak_dbfs=peak, + volume=volume, + pan=pan, + muted=muted, + solo=solo, + clipped=clipped, + effects=effects, + peak_hold_dbfs=peak, + ) + + +def test_debug_overlay_builds_compact_audio_mixer_lines(): + master = _bus("Master", peak=(-3.0, -2.0)) + sfx = _bus( + "SFX", + parent=master, + peak=(-8.0, -7.5), + volume=0.8, + pan=-0.25, + muted=True, + effects=(object(), object()), + ) + weapons = _bus( + "Weapons", + parent=sfx, + peak=(-6.0, -5.0), + solo=True, + clipped=True, + ) + + sends = ( + SimpleNamespace(source_bus_id=sfx.id), + SimpleNamespace(source_bus_id=weapons.id), + ) + + audio = SimpleNamespace( + player=SimpleNamespace(sources=(object(), object())), + device=SimpleNamespace(initialized=False), + get_buses=lambda: (master, sfx, weapons), + get_sends=lambda: sends, + ) + + overlay = object.__new__(DebugOverlay) + overlay.engine = SimpleNamespace(audio=audio) + + lines = overlay._build_audio_mixer_lines() + + assert "Audio Mixer" in lines + assert "Sources: 2 Buses: 3 Sends: 2" in lines + assert any("Master:" in line and "V 1.00" in line for line in lines) + assert any("SFX:" in line and "FX 2" in line and "Send 1" in line and "[M]" in line for line in lines) + assert any("Weapons:" in line and "[S CLIP]" in line for line in lines) + + +def test_debug_overlay_limits_audio_bus_rows(): + buses = tuple(_bus(f"Bus{i}") for i in range(11)) + audio = SimpleNamespace( + player=SimpleNamespace(sources=()), + device=SimpleNamespace(initialized=False), + get_buses=lambda: buses, + get_sends=lambda: (), + ) + + overlay = object.__new__(DebugOverlay) + overlay.engine = SimpleNamespace(audio=audio) + + lines = overlay._build_audio_mixer_lines() + + assert "... +3 buses" in lines + + +def test_debug_overlay_formats_silent_dbfs(): + assert DebugOverlay._format_dbfs(float("-inf")) == "-inf" + assert DebugOverlay._format_dbfs(-120.0) == "<-99" + assert DebugOverlay._format_dbfs(-12.34) == "-12.3" + + +def test_debug_overlay_dbfs_meter_mapping(): + assert DebugOverlay._dbfs_to_meter(float("-inf")) == 0.0 + assert DebugOverlay._dbfs_to_meter(-60.0) == 0.0 + assert DebugOverlay._dbfs_to_meter(-30.0) == 0.5 + assert DebugOverlay._dbfs_to_meter(0.0) == 1.0 + assert DebugOverlay._dbfs_to_meter(3.0) == 1.0 + + +def test_debug_overlay_draws_separate_left_and_right_meter_tracks(): + master = _bus("Master", peak=(-12.0, -6.0), clipped=True) + audio = SimpleNamespace( + player=SimpleNamespace(sources=()), + device=SimpleNamespace(initialized=False), + get_buses=lambda: (master,), + get_sends=lambda: (), + ) + overlay = object.__new__(DebugOverlay) + overlay.engine = SimpleNamespace(audio=audio) + overlay.margin = 16.0 + overlay.text_scale = 1.05 + overlay.line_spacing = 5.0 + + class Renderer: + width = 1920 + height = 1080 + + def __init__(self): + self.rects = [] + self.texts = [] + + def text_baseline(self, *, scale=1.0): + return 10.0 * scale + + def text_measure(self, text, *, scale=1.0): + return (len(text) * 7.0 * scale, 12.0 * scale) + + def rect(self, *args, **kwargs): + self.rects.append((args, kwargs)) + + def text(self, *args, **kwargs): + self.texts.append((args, kwargs)) + + renderer = Renderer() + lines = [ + "FPS", + "", + "Audio Mixer", + "------------------------", + "Sources: 0 Buses: 1 Sends: 0", + "Queue: N/A", + "Master: V 1.00 P +0.00 FX 0 Send 0 [CLIP]", + ] + overlay._draw_audio_mixer_meters(renderer, lines) + + labels = [args[0] for args, _ in renderer.texts] + assert "L" in labels + assert "R" in labels + # At least two backgrounds plus level fills / hold / clip markers. + assert len(renderer.rects) >= 6 diff --git a/tests/test_editor_asset_browser.py b/tests/test_editor_asset_browser.py index 6c5a218..cb2e584 100644 --- a/tests/test_editor_asset_browser.py +++ b/tests/test_editor_asset_browser.py @@ -21,9 +21,14 @@ def test_classify_asset(tmp_path: Path): prefab = tmp_path / "enemy.nxprefab" prefab.write_text("{}", encoding="utf-8") + tilemap = tmp_path / "world.ntmap" + tilemap.write_text("{}", encoding="utf-8") + assert classify_asset(image) == "image" assert classify_asset(scene) == "scene" assert classify_asset(prefab) == "prefab" + assert classify_asset(tilemap) == "tilemap" + assert classify_asset(tmp_path / "world.mapdata") == "file" assert classify_asset(tmp_path) == "folder" diff --git a/tests/test_editor_cli.py b/tests/test_editor_cli.py index 31606e8..97e4f9f 100644 --- a/tests/test_editor_cli.py +++ b/tests/test_editor_cli.py @@ -167,3 +167,19 @@ def test_resolve_project_path_rejects_file( resolve_project_path( file_path ) + + +def test_cli_audio_editor_defaults_to_current_directory() -> None: + parser = build_parser() + args = parser.parse_args(["--audioedit"]) + + assert args.audioedit == "." + assert args.command is None + + +def test_cli_audio_editor_accepts_project_path() -> None: + parser = build_parser() + args = parser.parse_args(["--audioedit", "MyProject"]) + + assert args.audioedit == "MyProject" + assert args.command is None diff --git a/tests/test_editor_ui_package.py b/tests/test_editor_ui_package.py new file mode 100644 index 0000000..8a0acef --- /dev/null +++ b/tests/test_editor_ui_package.py @@ -0,0 +1,56 @@ +"""Contract tests for the shared standalone-editor UI package.""" + +from pathlib import Path + +from nexora.editor import standalone_ui +from nexora.editor import ui +from nexora.editor.model import EditorProjectContext +from nexora.editor.standalone_tilemap_editor import StandaloneTileMapEditorApp + + +def test_shared_ui_package_exports_the_legacy_widget_surface() -> None: + for name in ui.__all__: + assert getattr(ui, name) is getattr(standalone_ui, name) + + +def test_shared_ui_package_exposes_the_common_editor_controls() -> None: + assert {"Button", "Menu", "TextField", "ListBox"}.issubset(ui.__all__) + + +def test_file_browser_model_confines_navigation_and_filters_files(tmp_path: Path) -> None: + assets = tmp_path / "assets" + nested = assets / "nested" + nested.mkdir(parents=True) + (assets / "tileset.png").write_bytes(b"") + (assets / "ignore.txt").write_text("ignored", encoding="utf-8") + (nested / "nested.png").write_bytes(b"") + + browser = ui.FileBrowserModel() + browser.open(tmp_path, start=assets, extensions=(".png",)) + + assert browser.labels == ["[..]", "[nested]", "tileset.png"] + assert browser.select(1) is None + assert browser.path == nested.resolve() + assert browser.labels == ["[..]", "nested.png"] + assert browser.select(0) is None + assert browser.path == assets.resolve() + + +def test_standalone_tilemap_editor_is_the_only_tilemap_editor_entrypoint() -> None: + from nexora.editor import run_standalone_tilemap_editor + + assert StandaloneTileMapEditorApp.__name__ == "StandaloneTileMapEditorApp" + assert callable(run_standalone_tilemap_editor) + + +def test_project_context_provides_shared_editor_roots(tmp_path: Path) -> None: + (tmp_path / "assets").mkdir() + (tmp_path / "items").mkdir() + (tmp_path / "cutscenes").mkdir() + + context = EditorProjectContext.from_path(tmp_path) + + assert context.root == tmp_path.resolve() + assert context.assets_root == (tmp_path / "assets").resolve() + assert context.items_root == (tmp_path / "items").resolve() + assert context.cutscenes_root == (tmp_path / "cutscenes").resolve() diff --git a/tests/test_settings_integration.py b/tests/test_settings_integration.py index e38d744..f90be80 100644 --- a/tests/test_settings_integration.py +++ b/tests/test_settings_integration.py @@ -105,13 +105,6 @@ def make_audio_defaults( return write_text( root / "audio.toml", """ -[volume] -master = 1.0 -music = 1.0 -sfx = 1.0 -ambient = 1.0 -voice = 1.0 - [buses.master] volume = 1.0 muted = false @@ -484,13 +477,6 @@ def test_audio_settings_load_and_write_only_override( settings._data = {} settings._user_data = {} - settings.volume = ( - AudioSection( - settings, - "volume", - ) - ) - settings.buses = ( AudioSection( settings, @@ -501,7 +487,7 @@ def test_audio_settings_load_and_write_only_override( settings.reload() assert ( - settings.volume.master + settings.buses.master.volume == 1.0 ) @@ -515,10 +501,10 @@ def test_audio_settings_load_and_write_only_override( is False ) - settings.volume.music = 0.5 + settings.buses.music.volume = 0.5 assert ( - settings.volume.music + settings.buses.music.volume == 0.5 ) @@ -529,17 +515,17 @@ def test_audio_settings_load_and_write_only_override( ) assert ( - "[volume]" + "[buses.music]" in text ) assert ( - "music = 0.5" + "volume = 0.5" in text ) assert ( - "master =" + "[buses.master]" not in text ) @@ -578,13 +564,6 @@ def test_audio_bus_override_and_reset( settings._data = {} settings._user_data = {} - settings.volume = ( - AudioSection( - settings, - "volume", - ) - ) - settings.buses = ( AudioSection( settings, @@ -790,13 +769,6 @@ def test_settings_systems_do_not_overwrite_each_other( audio._data = {} audio._user_data = {} - audio.volume = ( - AudioSection( - audio, - "volume", - ) - ) - audio.buses = ( AudioSection( audio, @@ -807,7 +779,7 @@ def test_settings_systems_do_not_overwrite_each_other( audio.reload() graphics.window.width = 1600 - audio.volume.music = 0.4 + audio.buses.music.volume = 0.4 graphics_text = ( graphics.store.user_path.read_text( @@ -827,12 +799,12 @@ def test_settings_systems_do_not_overwrite_each_other( ) assert ( - "music = 0.4" + "volume = 0.4" not in graphics_text ) assert ( - "music = 0.4" + "volume = 0.4" in audio_text ) diff --git a/tests/test_tilemap_asset.py b/tests/test_tilemap_asset.py index d5e22aa..67eb4d0 100644 --- a/tests/test_tilemap_asset.py +++ b/tests/test_tilemap_asset.py @@ -29,7 +29,7 @@ def test_tilemap_asset_roundtrip_preserves_tileset_layout_and_layers(tmp_path: P ) loaded_map, loaded_tileset = TileMapAsset.load(path).build() - assert path.name == "world.tilemap.net" + assert path.name == "world.ntmap" assert loaded_map.projection.value == "angled_2d" assert loaded_map.require_layer("ground").get_tile(2, 1) == 7 assert loaded_map.require_layer("objects").render_layer == 20 @@ -57,13 +57,13 @@ def test_tilemap_node_loads_tilemap_asset_from_assets(tmp_path: Path) -> None: tilemap = TileMap(width=1, height=1, tile_width=16, tile_height=16) tilemap.create_layer("ground").set_tile(0, 0, 2) tileset = TileSet(columns=2, rows=1, tile_width=16, tile_height=16, texture_asset="tiles.png") - asset_path = TileMapAsset.from_components(tilemap, tileset).save(assets_root / "world.tilemap.net") + asset_path = TileMapAsset.from_components(tilemap, tileset).save(assets_root / "world.ntmap") assets = _FakeAssets(assets_root) node = TileMapNode("World", World()) - node.load_tilemap_asset("world.tilemap.net", assets) + node.load_tilemap_asset("world.ntmap", assets) - assert node.tilemap_asset == "world.tilemap.net" + assert node.tilemap_asset == "world.ntmap" assert node.tilemap.require_layer("ground").get_tile(0, 0) == 2 assert assets.calls == ["tiles.png"]