main a1915cb511cc cached
17 files
38.4 KB
11.1k tokens
1 requests
Download .txt
Repository: WhoStoleMyCoffee/raytraced-audio
Branch: main
Commit: a1915cb511cc
Files: 17
Total size: 38.4 KB

Directory structure:
gitextract_3z3d8ftl/

├── .editorconfig
├── .gitattributes
├── .github/
│   └── FUNDING.yml
├── .gitignore
├── LICENSE
├── README.md
├── addons/
│   └── raytraced_audio/
│       ├── LICENSE
│       ├── README.md
│       ├── audio_ray.gd
│       ├── plugin.cfg
│       ├── plugin.gd
│       ├── raytraced_audio_listener.gd
│       └── raytraced_audio_player_3d.gd
├── default_bus_layout.tres
├── icon.png.import
├── icon.svg.import
└── project.godot

================================================
FILE CONTENTS
================================================

================================================
FILE: .editorconfig
================================================
root = true

[*]
charset = utf-8


================================================
FILE: .gitattributes
================================================
# Normalize EOL for all files that Git considers text files.
* text=auto eol=lf

# Only include the adodns folder when downloading from the Asset Library
/**        export-ignore
/addons    !export-ignore
/addons/** !export-ignore


================================================
FILE: .github/FUNDING.yml
================================================
# These are supported funding model platforms

github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: tienne_k
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']


================================================
FILE: .gitignore
================================================
test_scene/
*.uid

# Godot 4+ specific ignores
.godot/
/android/


================================================
FILE: LICENSE
================================================
MIT License

Copyright (c) 2025 Tienne_k

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.


================================================
FILE: README.md
================================================
# raytraced-audio
Adds procedural audio effects to Godot like echo, ambient outdoor sounds, and muffle.

[Showcase Video](https://youtu.be/rFauZQ-tFTg?si=eUklNxLSs8ew2Zp6)

Check out [this amazing video](https://youtu.be/u6EuAUjq92k?si=6W-sGozYBQITEgQo) by **Vercidium** for insights on how this works!

No need to create and maintain zones while creating levels, just put the `RaytracedAudioListener` node in your scene and most features will be available for you to use!

Additionally, by using `RaytracedAudioPlayer3D`s instead of regular `AudioPlayer3D`s, sounds will get automatically muffled behind walls.

### Audio buses
Right off the bat, this plugin creates 2 new audio buses for you to use across your project:
a *"Reverb"* bus, and an *"Ambient"* bus.
Note: both buses' names can be changed under `Project Settings > Raytraced Audio`.

The reverb bus controls echo / reverb.
For example, there will be a much bigger reverb in large enclosed rooms compared to small ones, or outside in the open.

The ambient bus controls the strength and pan of sounds coming from outside.
For example, in a room with a single opening leading outisde, sounds in this bus will appear to come from that opening, and will fade based on the player's distance to it.

### Performace

Because the rays need to gather different informations about the environment, the actual number of processed rays can go up to:

`rays_count * (2 + n)` where `n` is the number of enabled `RaytracedAudioPlayer3D`s in the scene.

That is:

`(rays_count: configurable) * (1 ray that bounces around + 1 echo ray + (1 muffle ray per enabled RaytracedAudioPlayer3D))`

Raytraced Audio also adds 2 performance monitors:
 - `raytraced_audio/raycast_updates`: How many raycast updates happened in the update tick
 - `raytraced_audio/enabled_players_count`: How many `RaytracedAudioPlayer3D`s are currently enabled in the scene


### Installation

#### Manual installation

- Download or clone this repository
- Copy the `addons/raytraced_audio` folder into your project's `addons/` folder
- Enable the plugin in `Project Settings > Plugins > Raytraced Audio`



================================================
FILE: addons/raytraced_audio/LICENSE
================================================
MIT License

Copyright (c) 2025 Tienne_k

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.


================================================
FILE: addons/raytraced_audio/README.md
================================================
# raytraced-audio
Adds procedural audio effects to Godot like echo, ambient outdoor sounds, and muffle.

Check out [this amazing video](https://youtu.be/u6EuAUjq92k?si=6W-sGozYBQITEgQo) by **Vercidium** for insights on how this works!

No need to create and maintain zones while creating levels, just put the `RaytracedAudioListener` node in your scene and most features will be available for you to use!

Additionally, by using `RaytracedAudioPlayer3D`s instead of regular `AudioPlayer3D`s, sounds will get automatically muffled behind walls.

### Audio buses
Right off the bat, this plugin creates 2 new audio buses for you to use across your project:
a *"Reverb"* bus, and an *"Ambient"* bus.
Note: both buses' names can be changed under `Project Settings > Raytraced Audio`.

The reverb bus controls echo / reverb.
For example, there will be a much bigger reverb in large enclosed rooms compared to small ones, or outside in the open.

The ambient bus controls the strength and pan of sounds coming from outside.
For example, in a room with a single opening leading outisde, sounds in this bus will appear to come from that opening, and will fade based on the player's distance to it.

### Performace

Because the rays need to gather different informations about the environment, the actual number of processed rays can go up to:

`rays_count * (2 + n)` where `n` is the number of enabled `RaytracedAudioPlayer3D`s in the scene.

That is:

`(rays_count: configurable) * (1 ray that bounces around + 1 echo ray + (1 muffle ray per enabled RaytracedAudioPlayer3D))`

Raytraced Audio also adds 2 performance monitors:
 - `raytraced_audio/raycast_updates`: How many raycast updates happened in the update tick
 - `raytraced_audio/enabled_players_count`: How many `RaytracedAudioPlayer3D`s are currently enabled in the scene


### Installation

#### Manual installation

- Download or clone this repository
- Copy the `addons/raytraced_audio` folder into your project's `addons/` folder
- Enable the plugin in `Project Settings > Plugins > Raytraced Audio`



================================================
FILE: addons/raytraced_audio/audio_ray.gd
================================================
extends RayCast3D

# dont you worry about this class, habibi
# shhhhhh its okay
# ( -  ͜ʖ -)☞(ʘ_ʘ; )

var cast_dist: float = 0.0
var max_bounces: int = 1
var ray_scatter: Callable

var echo_dist: float = 0.0
var echo_count: int = 0
var bounces: int = 0
var has_bounced_this_tick: bool = false
var ray_casts_this_tick: int = 0
var escaped: bool = false
var escape_dir: Vector3 = Vector3.ZERO

var _space_state: PhysicsDirectSpaceState3D


func _init(raycast_dist: float, max_bounce_count: int) -> void:
	top_level = true
	enabled = false
	cast_dist = raycast_dist
	max_bounces = max_bounce_count


func _enter_tree() -> void:
	owner = get_parent()
	assert(owner is RaytracedAudioListener)


func _ready() -> void:
	_space_state = get_world_3d().direct_space_state
	reset()


# TODO: optimize
func update():
	# Reset if needed
	if escaped or bounces > max_bounces:
		reset()

	force_raycast_update()
	ray_casts_this_tick += 1
	bounces += 1

	# Escaped outside
	if !is_colliding():
		escaped = true
		global_position += target_position
		# Muffle ray: Check for line of sight with audio players
		for player: RaytracedAudioPlayer3D in get_tree().get_nodes_in_group(RaytracedAudioPlayer3D.ENABLED_GROUP_NAME):
			var has_line_of_sight: bool = _cast_ray(player.global_position).is_empty()
			player._lowpass_rays_count += int(has_line_of_sight)
		return

	# Bounce
	var hit_pos: Vector3 = get_collision_point()
	var normal: Vector3 = get_collision_normal()
	global_position = hit_pos + normal * 0.1
	target_position = target_position.bounce(normal)
	has_bounced_this_tick = true

	# Muffle ray: Check for line of sight with audio players
	for player: RaytracedAudioPlayer3D in get_tree().get_nodes_in_group(RaytracedAudioPlayer3D.ENABLED_GROUP_NAME):
		var has_line_of_sight: bool = _cast_ray(player.global_position).is_empty()
		player._lowpass_rays_count += int(has_line_of_sight)
		# Same as:
		# if has_line_of_sight:
		# 	player.lowpass_rays_count += 1

	# Echo ray: Check for line of light with listener
	# Optimization: no need to raycast for first echo bounce
	if bounces == 1:
		echo_dist = hit_pos.distance_to(owner.global_position)
		echo_count += 1
		escape_dir = target_position.normalized()
	elif _cast_ray(owner.global_position).is_empty():
		# The way is clear -> echo
		echo_dist = hit_pos.distance_to(owner.global_position)
		echo_count += 1
		escape_dir = owner.global_position.direction_to(hit_pos)


# Return to the listener with a random direction
func reset():
	var dir: Vector3 = ray_scatter.call()
	
	global_position = owner.global_position
	target_position = dir * cast_dist

	bounces = 0
	escaped = false
	escape_dir = dir
	reset_tick_stats()


# Called after this ray is done ticking, in RaytracedAudioListener
func reset_tick_stats() -> void:
	has_bounced_this_tick = false
	ray_casts_this_tick = 0
	echo_dist = 0.0
	echo_count = 0


func set_scatter_model(model: RaytracedAudioListener.RayScatterModel) -> void:
	match model:
		RaytracedAudioListener.RayScatterModel.RANDOM:
			ray_scatter = _random_dir
		RaytracedAudioListener.RayScatterModel.XZ:
			ray_scatter = _random_dir_xz_plane
		_:
			push_error("Unknown ray scatter model: '", model, "'")
			ray_scatter = _random_dir



func _cast_ray(to: Vector3) -> Dictionary:
	var params: PhysicsRayQueryParameters3D = PhysicsRayQueryParameters3D.create(
		global_position,
		to,
		0b01
	)
	ray_casts_this_tick += 1
	return _space_state.intersect_ray(params)


func _random_dir_xz_plane() -> Vector3:
	var  yaw = randf_range(0.0, TAU)
	return Vector3(-sin(yaw), 0.0, -cos(yaw))

func _random_dir() -> Vector3:
	var theta: float = randf_range(0.0, TAU)
	var y: float = randf_range(-1.0, 1.0)
	var k: float = sqrt(1.0 - y*y)
	return Vector3(k * cos(theta), k * sin(theta), y)


================================================
FILE: addons/raytraced_audio/plugin.cfg
================================================
[plugin]

name="Raytraced Audio"
description="Adds procedural audio effects to Godot like echo, ambient outdoor sounds, and muffle"
author="Tienne_k"
version="1.0"
script="plugin.gd"


================================================
FILE: addons/raytraced_audio/plugin.gd
================================================
@tool
extends EditorPlugin


func _enter_tree() -> void:
	_setup_settings()

	# For some reason, adding custom types like this fails to generate documentation sometimes??
	# add_custom_type(
	# 	"RaytracedAudioListener",
	# 	"AudioListener3D",
	# 	load("res://addons/raytraced_audio/raytraced_audio_listener.gd"),
	# 	null
	# )
	# add_custom_type(
	# 	"RaytracedAudioPlayer3D",
	# 	"AudioStreamPlayer3D",
	# 	load("res://addons/raytraced_audio/raytraced_audio_player_3d.gd"),
	# 	null
	# )

	_setup_audio_buses()


func _exit_tree() -> void:
	# remove_custom_type("RaytracedAudioListener")
	# remove_custom_type("RaytracedAudioPlayer3D")

	_clean_up_settings()
	_clean_up_audio_buses()


func _setup_settings() -> void:
	ProjectSettings.set_setting("raytraced_audio/reverb_bus", &"RaytracedReverb")
	ProjectSettings.set_setting("raytraced_audio/ambient_bus", &"RaytracedAmbient")

	if ProjectSettings.get_setting("audio/general/3d_panning_strength") < 1.0:
		print("[INFO] RaytracedAudio: I recommend setting Audio/General/3d_panning_strength in Project Settings to 1.0 or above")


func _clean_up_settings() -> void:
	ProjectSettings.set_setting("raytraced_audio/reverb_bus", null)
	ProjectSettings.set_setting("raytraced_audio/ambient_bus", null)


func _setup_audio_buses() -> void:
	print("[INFO] Raytraced Audio: setting up audio buses")
	_clean_up_audio_buses()

	# Reverb
	var i: int = AudioServer.bus_count
	AudioServer.add_bus()
	AudioServer.set_bus_name(i, ProjectSettings.get_setting("raytraced_audio/reverb_bus", &"RaytracedReverb"))
	AudioServer.set_bus_send(i, &"Master")
	var reverb: AudioEffectReverb = AudioEffectReverb.new()
	reverb.hipass = 1.0
	reverb.resource_name = "reverb"
	AudioServer.add_bus_effect(i, reverb)

	# Ambient
	i = AudioServer.bus_count
	AudioServer.add_bus()
	AudioServer.set_bus_name(i, ProjectSettings.get_setting("raytraced_audio/ambient_bus", &"RaytracedAmbient"))
	AudioServer.set_bus_send(i, &"Master")
	var panner: AudioEffectPanner = AudioEffectPanner.new()
	panner.resource_name = "pan"
	AudioServer.add_bus_effect(i, panner)

	# There's a bug in Godot that makes it so the ambient bus doesnt show properly in the editor
	# This is "fixable" by adding a temporary bus afterwards and deleting it immediately because ofc.
	# fuck you (respectfully)
	i = AudioServer.bus_count
	AudioServer.add_bus()
	AudioServer.remove_bus(i)


func _clean_up_audio_buses() -> void:
	var i: int = AudioServer.get_bus_index(ProjectSettings.get_setting("raytraced_audio/reverb_bus", &"RaytracedReverb"))
	if i != -1:
		AudioServer.remove_bus(i)
	i = AudioServer.get_bus_index(ProjectSettings.get_setting("raytraced_audio/ambient_bus", &"RaytracedAmbient"))
	if i != -1:
		AudioServer.remove_bus(i)


================================================
FILE: addons/raytraced_audio/raytraced_audio_listener.gd
================================================
class_name RaytracedAudioListener extends AudioListener3D
## 3D audio listener for raytraced audio
##
## [b]Audio buses[/b]
## [br]This plugin creates 2 new audio buses for you to use across your project:
## a [i]"Reverb"[/i] bus, and an [i]"Ambient"[/i] bus.
## [br]Note: both buses' names can be changed under [code]Project Settings > Raytraced Audio[/code].
## [br]
## [br]The reverb bus controls echo / reverb.
## [br]For example, there will be a much bigger reverb in large enclosed rooms compared to small ones, or outside in the open
## [br]
## [br]The ambient bus controls the strength and pan of sounds coming from outside.
## [br]For example, in a room with a single opening leading outisde, sounds in this bus will appear to come from that opening, and will fade based on the player's distance to it
## [br]
## [br][b]Performace[/b]
## [br]Raytraced Audio adds 2 performance monitors:
## [br] - [code]raytraced_audio/raycast_updates[/code]: How many raycast updates happened in one update tick
## [br] - [code]raytraced_audio/enabled_players_count[/code]: How many [RaytracedAudioPlayer3D]s are enabled in the scene
## [br]
## [br]Note: There should be only one [RaytracedAudioListener] in a given scene, just like how there should be only one AudioListener3D in a scene.
## [br]See also [RaytracedAudioPlayer3D]

## Speed of sound in m/s
const SPEED_OF_SOUND: float = 343.0
## All [RaytracedAudioListener]s will be in this group
const GROUP_NAME: StringName = &"raytraced_audio_listener"

const AudioRay: Script = preload("res://addons/raytraced_audio/audio_ray.gd")

enum RayScatterModel {
	## Rays will be shot out in a random 3d direction
	RANDOM,
	## Rays will be shot out on the listener's XZ plane (i.e. [code]Vector3(random, 0, random)[/code])
	XZ,
}

## Emitted when this node is enabled
signal enabled
## Emitted when this node is disabled
signal disabled
## Emitted when any configuration for the rays are changed
## [br]This includes: [member rays_count], [member max_raycast_dist], [member max_bounces], and [member ray_scatter_model]
signal ray_configuration_changed

## List of rays instanced by this node
var rays: Array[AudioRay] = []

## Enable or disable raycasting
## [br]Disabled nodes can't be updated (see [method update]) even if [member auto_update] is set to [code]true[/code]
@export var is_enabled: bool = true:
	set(v):
		if is_enabled == v:
			return
		is_enabled = v

		if is_enabled:
			if is_node_ready():
				setup()
			enabled.emit()
		else:
			disabled.emit()
			if is_node_ready():
				clear()

## Whether to update automatically
## [br]If set to [code]true[/code], this [RaytracedAudioListener] will update every [i]process[/i] frame
@export var auto_update: bool = true:
	set(v):
		auto_update = v
		set_process(auto_update)

## Number of rays to use
## [br]More rays and more bounces mean a more accurate model of the environment can be made
## [br] See also [member max_bounces]
## [br]
## [br][i]Technical note[/i]:
## [br] Because the rays need to gather different informations about the environment,
##  the actual number of processed rays can go up to:
## [br] [code]rays_count * (2 + n)[/code] where [code]n[/code] is the number of enabled [RaytracedAudioPlayer3D]s in the scene
## [br] ([code]rays_count * (1 ray that bounces around + 1 echo ray + 1 muffle ray per enabled RaytracedAudioPlayer3D[/code])
@export var rays_count: int = 4:
	set(v):
		if v == rays_count:
			return
		rays_count = maxi(v, 1)
		clear()
		setup()
		ray_configuration_changed.emit()

## The maximum distance which any given ray instanced by this node will cast
@export var max_raycast_dist: float = SPEED_OF_SOUND:
	set(v):
		max_raycast_dist = v
		_update_ray_configuration()
		ray_configuration_changed.emit()
## [br]More rays and more bounces mean a more accurate model of the environment can be made
## [br] See also [member rays_count]
@export var max_bounces: int = 3:
	set(v):
		max_bounces = v
		_update_ray_configuration()
		ray_configuration_changed.emit()

## Controls how rays will be instanced
@export var ray_scatter_model: RayScatterModel = RayScatterModel.RANDOM:
	set(v):
		ray_scatter_model = v
		_update_ray_configuration()
		ray_configuration_changed.emit()

@export_category("Muffle")
## Enables [RaytracedAudioPlayer3D]s muffling behind walls
@export var muffle_enabled: bool = true
## The interpolation strength of the muffle
## [br]See [member muffle_enabled]
@export_range(1.0, 25.0, 0.1) var muffle_interpolation: float = 5.0
@export_category("Echo")
## Enables updates to the reverb audio bus
## [br]See [code]Project Settings > Raytraced Audio > Reverb Bus[/code]
@export var echo_enabled: bool = true
## The "intensity" of the reverb
## [br]More specifically, multiplies the reverb's room size by this amount
## [br]The default is [code]2.0[/code] to account for sound waves coming back to the listener after hitting a wall
@export var echo_room_size_multiplier: float = 2.0
## The interpolation strength of the echo
## [br]See [member echo_enabled]
@export_range(1.0, 25.0, 0.1) var echo_interpolation: float = 5.0
@export_category("Ambient")
## Enables updates to the ambient audio bus
## [br]See [code]Project Settings > Raytraced Audio > Ambient Bus[/code]
@export var ambient_enabled: bool = true
## The interpolation strength of ambient sounds' direction (pan)
## [br]See [member ambient_enabled], [member ambient_pan_strength]
@export_range(1.0, 25.0, 0.1) var ambient_pan_interpolation: float = 5.0
## How strong the pan between right and left ear will be
## Setting this to 0 disables panning completely
## [br]See [member ambient_enabled], [member ambient_pan_interpolation]
@export_range(0.0, 1.0, 0.01) var ambient_pan_strength: float = 1.0
## The interpolation strength of ambient sounds' volume
## [br]See [member ambient_enabled]
@export_range(1.0, 25.0, 0.1) var ambient_volume_interpolation: float = 5.0
## How smoothly the ambient sounds will fade away when the [AudioListener3D] is no longer "outside"
## Values close to 1 will make sounds linger for longer, while values close to 0 will make them drop suddenly
## [br]See [member ambient_enabled]
@export_range(0.0, 1.0, 0.001) var ambient_volume_attenuation: float = 0.998

## The size of the room based on data gathered from rays, in world units
## [br]Used in reverb calculation
var room_size: float = 0.0
## How "outside" this [RaytracedAudioListener] is based on data gathered from rays, between 0 and 1
var ambience: float = 0.0
## The direction to "outside" based on data gathered from rays
## [br]Should average out to 0 when this [RaytracedAudioListener] is completely outside
var ambient_dir: Vector3 = Vector3.ZERO

## For debugging purposes
## [br]See the [code]raytraced_audio/raycast_updates[/code] performance monitor
var ray_casts_this_tick: int = 0

var _reverb_effect: AudioEffectReverb
var _pan_effect: AudioEffectPanner

# Keep track of whether we've set up debug monitors
var _debug_monitors_setup: bool = false


func _enter_tree() -> void:
	add_to_group(GROUP_NAME)


func _ready() -> void:
	# Reverb effect
	var i: int = AudioServer.get_bus_index(ProjectSettings.get_setting("raytraced_audio/reverb_bus"))
	if i == -1:
		push_error("Failed to get reverb bus for raytraced audio. Disabling echo...")
		echo_enabled = false
	else:
		_reverb_effect = AudioServer.get_bus_effect(i, 0)

	# Pan effect
	i = AudioServer.get_bus_index(ProjectSettings.get_setting("raytraced_audio/ambient_bus"))
	if i == -1:
		push_error("Failed to get ambient bus for raytraced audio. Disabling ambience...")
		ambient_enabled = false
	else:
		_pan_effect = AudioServer.get_bus_effect(i, 0)

	if is_enabled:
		setup()

	set_process(auto_update)
	if is_enabled:
		make_current()

	_setup_debug()


func _exit_tree() -> void:
	_cleanup_debug()


func _setup_debug() -> void:
	if _debug_monitors_setup:
		return
		
	_debug_monitors_setup = true
	
	Performance.add_custom_monitor(&"raytraced_audio/raycast_updates", func():
		# Check if this instance is still valid
		if not is_instance_valid(self):
			return 0
		return ray_casts_this_tick
	)

	Performance.add_custom_monitor(&"raytraced_audio/enabled_players_count", func():
		# Check if this instance is still valid and if we're in a scene tree
		if not is_instance_valid(self) or not is_inside_tree():
			return 0
		return get_tree().get_node_count_in_group(RaytracedAudioPlayer3D.ENABLED_GROUP_NAME)
	)


func _cleanup_debug() -> void:
	if not _debug_monitors_setup:
		return
		
	_debug_monitors_setup = false
	
	# Remove the custom monitors
	Performance.remove_custom_monitor(&"raytraced_audio/raycast_updates")
	Performance.remove_custom_monitor(&"raytraced_audio/enabled_players_count")
	


## Initiates this [RaytracedAudioListener]'s rays
func setup() -> void:
	for __ in rays_count:
		var rc: AudioRay = AudioRay.new(max_raycast_dist, max_bounces)
		rc.set_scatter_model(ray_scatter_model)
		add_child(rc, INTERNAL_MODE_BACK)
		rays.push_back(rc)


## Clears all created rays
func clear():
	for ray: AudioRay in rays:
		remove_child(ray)
		ray.queue_free()
	rays.clear()


func _process(delta: float) -> void:
	if !auto_update:
		set_process(false)
		return
	update(delta)


## Updates this [RaytracedAudioListener]
## [br]If you are updating this node manually (i.e. [member auto_update] is [code]false[/code]), this is the method to call
func update(delta: float):
	ray_casts_this_tick = 0
	if !is_enabled:
		return

	var echo: float = 0.0 # Avg echo from all rays
	var echo_count: int = 0 # Number of echo rays that came back
	var bounces_this_tick: int = 0
	var escaped_count: int = 0
	var escaped_dir: Vector3 = Vector3.ZERO # Avg escape direction
	var escaped_strength: float = 0.0

	# Gather data
	for ray: AudioRay in rays:
		ray.update()
		ray_casts_this_tick += ray.ray_casts_this_tick

		echo += ray.echo_dist
		echo_count += ray.echo_count
		bounces_this_tick += int(ray.has_bounced_this_tick)

		if ray.escaped:
			escaped_count += 1
			escaped_strength += 1.0 / ray.bounces
			escaped_dir += ray.escape_dir

		ray.reset_tick_stats()
	
	echo = 0.0 if echo_count == 0 else (echo / float(echo_count))
	escaped_dir = Vector3.ZERO if escaped_count == 0 else (escaped_dir / float(escaped_count))

	if muffle_enabled:
		_update_muffle(delta)
	if echo_enabled:
		_update_echo(echo, echo_count, bounces_this_tick, delta)
	if ambient_enabled:
		_update_ambient(escaped_strength, escaped_dir, delta)


func _update_muffle(delta: float) -> void:
	for player: RaytracedAudioPlayer3D in get_tree().get_nodes_in_group(RaytracedAudioPlayer3D.GROUP_NAME):
		player.update(self, delta)


func _update_echo(echo: float, echo_count: int, bounces: int, delta: float) -> void:
	# Length -> echo delay
	room_size = lerpf(room_size, echo, 1.0 - exp(-delta * echo_interpolation))
	var e: float = (room_size * echo_room_size_multiplier) / SPEED_OF_SOUND
	# print("e = ", e)
	_reverb_effect.room_size = lerpf(_reverb_effect.room_size, clampf(e, 0.0, 1.0), 1.0 - exp(-delta * echo_interpolation))
	_reverb_effect.predelay_msec = lerpf(_reverb_effect.predelay_msec, e * 1000, 1.0 - exp(-delta * echo_interpolation))
	_reverb_effect.predelay_feedback = lerpf(_reverb_effect.predelay_feedback, clampf(e, 0.0, 0.98), 1.0 - exp(-delta * echo_interpolation))
	# More rays % -> echo strength
	var return_ratio: float = 0.0 if bounces == 0 else float(echo_count) / float(bounces)
	_reverb_effect.hipass = lerpf(_reverb_effect.hipass, 1.0 - return_ratio, 1.0 - exp(-delta * echo_interpolation))


func _update_ambient(escaped_strength: float, escaped_dir: Vector3, delta: float) -> void:
	var ambience_ratio: float = float(escaped_strength) / float(rays_count)

	# More rays % -> louder
	if escaped_strength > 0:
		#should it use delta? 
		#ambience = lerpf(ambience, 1.0, 1.0 - exp(-delta * ambience_ratio))
		ambience = lerpf(ambience, 1.0, ambience_ratio)
	else:
		ambience *= ambient_volume_attenuation
	var ambient_bus_idx: int = AudioServer.get_bus_index(ProjectSettings.get_setting("raytraced_audio/ambient_bus"))
	var volume: float = AudioServer.get_bus_volume_linear(ambient_bus_idx)
	AudioServer.set_bus_volume_linear(ambient_bus_idx, lerpf(volume, ambience, 1.0 - exp(-delta * ambient_volume_interpolation)))
	
	# Avg escape direction -> pan
	ambient_dir = ambient_dir.slerp(escaped_dir, 1.0 - exp(-delta * ambient_pan_interpolation))
	var target_pan: float = 0.0 if ambient_dir.is_zero_approx() else owner.transform.basis.x.dot(ambient_dir.normalized())
	_pan_effect.pan = target_pan * ambient_pan_strength


# ✨ the name says it all ✨
func _update_ray_configuration() -> void:
	for ray: AudioRay in rays:
		ray.cast_dist = max_raycast_dist
		ray.max_bounces = max_bounces
		ray.set_scatter_model(ray_scatter_model)


================================================
FILE: addons/raytraced_audio/raytraced_audio_player_3d.gd
================================================
class_name RaytracedAudioPlayer3D extends AudioStreamPlayer3D
## Audio stream player that allows for audio muffling
## 
## You can already use the reverb and ambient audio buses (see the documentation for [RaytracedAudioListener] for more details)
## to make use of raytraced audio effects, but using [RaytracedAudioPlayer3D]s allows you to use more capabilities.
## Namely, muffling sounds behind walls.
## [br]
## [br][b][color=yellow]Warning[/color][/b]: [RaytracedAudioPlayer3D]s will default to using the reverb audio bus
## [br]If you wish to use another bus instead, please set it after this node has been added to the scene tree (i.e. after [code]_enter_tree()[/code])
## [br]
## [br][i]Technical note[/i]:
## [br]Currently, there is no way to muffle only one audio player (or apply any effect for that matter).
## [br]So we create a new bus for every audio player that is audible from the [RaytracedAudioListener]
## [br]Godot doesn't provide methods to calculate the volume of an audio at certain distances, so we calculate
## that ourselves (see [method calculate_audible_distance_threshold()])

## All [RaytracedAudioPlayer3D]s (regardless of state) will be in this group
const GROUP_NAME: StringName = &"raytraced_audio_player_3d"
## All [b]enabled[/b] [RaytracedAudioPlayer3D]s will be in this group
const ENABLED_GROUP_NAME: StringName = &"enabled_raytraced_audio_player_3d"
## The lowpass frequency when completely muffled
const LOWPASS_MIN_HZ: float = 250.0
## The lowpass frequency when completely not-muffled (?)
const LOWPASS_MAX_HZ: float = 20000.0
## The factor the nyquist frequency is multiplied with to get a safe max_cutoff
const NYQUIST_MULTIPLIER: float = 0.95

## Used in internal calculations
const LOG2: float = log(2.0)
## Used in internal calculations
const LOG_MIN_HZ: float = log(LOWPASS_MIN_HZ) / LOG2
## Used in internal calculations
const LOG_MAX_HZ: float = log(LOWPASS_MAX_HZ) / LOG2

## Emitted when this node is enabled
## [br]See also [constant ENABLED_GROUP_NAME]
signal enabled
## Emitted when this node is disabled
signal disabled
## Emitted when the maximum audible distance for this node is changed
## [br]See also [member AudioStreamPlayer3D.max_distance] and [member audibility_threshold_db]
signal audible_distance_updated(distance: float)

## The threshold (in decibels) at which sounds will be considered inaudible
## [br]This is used to enable / disable this node when it's not audible to save resources
## [br]The distance at which this node is no longer considered audible is automatically stored inside [member AudioStreamPlayer3D.max_distance]
## (if not already set), also to save resources
## [br]Though, setting [member AudioStreamPlayer3D.max_distance] doesn't update this field automatically
## [br]See also [method get_volume_db_from_pos] and [method calculate_audible_distance_threshold]
@export var audibility_threshold_db: float = -30.0:
	set(v):
		audibility_threshold_db = v
		# Max distance not configured
		if is_node_ready() and max_distance == 0.0:
			max_distance = calculate_audible_distance_threshold()
			audible_distance_updated.emit(max_distance)

var _lowpass_rays_count: int = 0
var _lowpass_max_safe_cutoff: float = LOWPASS_MAX_HZ
var _is_enabled: bool = false


func _enter_tree() -> void:
	add_to_group(GROUP_NAME)
	bus = ProjectSettings.get_setting("raytraced_audio/reverb_bus") # Fallback

func _ready() -> void:
	# Max distance not configured
	if max_distance == 0.0:
		max_distance = calculate_audible_distance_threshold()
		audible_distance_updated.emit(max_distance)
	
	# Update the lowpass limits on start
	_lowpass_max_safe_cutoff = calculate_lowpass_limit()


## Enables this node
## [br]Note: you should almost never have to worry about enabling / disabling [RaytracedAudioPlayer3D]s manually
## [br]See [method update]
func enable():
	if _is_enabled:
		return
	_is_enabled = true
	var i: int = _create_bus()
	bus = AudioServer.get_bus_name(i)
	add_to_group(ENABLED_GROUP_NAME)

	enabled.emit()

## Enables this node
## [br]Note: you should almost never have to worry about enabling / disabling [RaytracedAudioPlayer3D]s manually
## [br]See [method update]
func disable():
	if !_is_enabled:
		return

	# Don't remove the fallback bus lol
	if bus == ProjectSettings.get_setting("raytraced_audio/reverb_bus"):
		_disable()
		return
	
	# Remove this node's specific bus
	var idx: int = AudioServer.get_bus_index(bus)
	if idx == -1:
		push_warning("audio bus ", bus, " not found")
		_disable()
		return
	_disable()
	AudioServer.remove_bus(idx)

func _disable():
	_is_enabled = false
	bus = ProjectSettings.get_setting("raytraced_audio/reverb_bus") # Fallback
	remove_from_group(ENABLED_GROUP_NAME)
	_lowpass_rays_count = 0

	disabled.emit()


# Returns the index of the created bus
func _create_bus() -> int:
	var i: int = AudioServer.bus_count
	AudioServer.add_bus()
	AudioServer.set_bus_name(i, generate_bus_name())
	AudioServer.set_bus_send(i, ProjectSettings.get_setting("raytraced_audio/reverb_bus"))
	AudioServer.add_bus_effect(i, AudioEffectLowPassFilter.new())
	return i

## Returns a name for the audio bus created for this node
func generate_bus_name() -> String:
	return str("RTAudioPlayer3D_", name, randi())

## ✨ the name says it all ✨
func is_enabled() -> bool:
	return _is_enabled

## Updates this [RaytracedAudioPlayer3D]
## [br]This method will adjust the muffle of the played stream, as well as enable or disable it
## based on whether it's audible from the given [RaytracedAudioListener]
## [br]If you are updating this node manually, this is the method to call
func update(listener: RaytracedAudioListener, delta: float) -> void:
	if _is_enabled:
		_update(listener.rays_count, listener.muffle_interpolation, delta)

	_lowpass_rays_count = 0
	
	# Enable based on position
	var dist_sq: float = global_position.distance_squared_to(listener.global_position)
	if dist_sq > max_distance*max_distance or !playing:
		disable()
	else:
		enable()


func _update(rays_count: int, interpolation: float, delta: float):
	if bus == ProjectSettings.get_setting("raytraced_audio/reverb_bus"):
		_disable()
		return

	var idx: int = AudioServer.get_bus_index(bus)
	if idx == -1:
		push_error("audio bus ", bus, " not found")
		_disable()
	else:
		var ratio: float = float(_lowpass_rays_count) / float(rays_count)
		ratio = clamp(ratio, 0, 1)
		var lowpass: AudioEffectLowPassFilter = AudioServer.get_bus_effect(idx, 0)

		# Frequencies aren't linear, they scale logarithmically (log2 space) +1 octave = 2x the frequency
		# So we scale frequencies down before lerping, then scale them back up
		var log_t: float = lerpf(LOG_MIN_HZ, LOG_MAX_HZ, ratio)
		var log_hz: float = log(lowpass.cutoff_hz) / LOG2 # Scale current frequency down:  log2(x) = ln(x) / ln(2)

		log_hz = lerpf(log_hz, log_t, 1.0 - exp(-delta * interpolation)) # Lerp in scaled down space
		lowpass.cutoff_hz = min(pow(2, log_hz), _lowpass_max_safe_cutoff) # Limit at max cutoff


# Translated from the godot repo
## Calculates the volume (in decibels) of the audio stream from the given position
## [br]Please note that the final volume will vary depending on the stream that is being played
func get_volume_db_from_pos(from_pos: Vector3) -> float:
	const CMP_EPSILON: float = 0.0001

	var dist: float = from_pos.distance_to(global_position)
	var vol: float = 0.0
	match attenuation_model:
		ATTENUATION_INVERSE_DISTANCE:
			vol = linear_to_db(1.0 / ((dist / unit_size) + CMP_EPSILON))
		ATTENUATION_INVERSE_SQUARE_DISTANCE:
			var d: float = (dist / unit_size)
			vol = linear_to_db(1.0 / (d*d + CMP_EPSILON))
		ATTENUATION_LOGARITHMIC:
			vol = -20.0 * log(dist / unit_size + CMP_EPSILON)
		ATTENUATION_DISABLED:
			pass
		_:
			push_error("Unknown attenuation type: '", attenuation_model, "'")
	vol = minf(vol + volume_db, max_db)
	return vol


## Calculates the distance at which this [RaytracedAudioPlayer3D] is no longer considered audible
## [br]See also [member audibility_threshold_db]
func calculate_audible_distance_threshold() -> float:
	if max_distance > 0.0:
		return max_distance
	
	match attenuation_model:
		ATTENUATION_INVERSE_DISTANCE:
			var t_lin: float = db_to_linear(audibility_threshold_db - volume_db)
			# Unit_size / dist < T_lin
			return unit_size / t_lin
		ATTENUATION_INVERSE_SQUARE_DISTANCE:
			var t_lin: float = db_to_linear(audibility_threshold_db - volume_db)
			# (Unit_size / dist)^2 < T_lin
			return sqrt(unit_size*unit_size / t_lin)
		ATTENUATION_LOGARITHMIC:
			var t_db: float = audibility_threshold_db - volume_db
			# -20 * log(dist / Unit_size) > T_db
			return exp(t_db / -20.0) * unit_size
		ATTENUATION_DISABLED:
			return 0.0
		_:
			push_error("Unknown attenuation model: '", attenuation_model, "'")
			return 0.0


## Gets the current sampling rate of the AudioServer and calculates 
## a safe cutoff_hz value for the lowpass filter.
func calculate_lowpass_limit() -> float:
	var nyquist_frequency = AudioServer.get_mix_rate() * 0.5
	return nyquist_frequency * NYQUIST_MULTIPLIER


## Checks wheter this [RaytracedAudioPlayer3D] is considered audible from the given position
## [br]See also [member audibility_threshold_db]
func is_audible(from_pos: Vector3) -> bool:
	return get_volume_db_from_pos(from_pos) >= audibility_threshold_db

================================================
FILE: default_bus_layout.tres
================================================
[gd_resource type="AudioBusLayout" format=3 uid="uid://dfuwe48pov8j3"]

[sub_resource type="AudioEffectReverb" id="AudioEffectReverb_j3pel"]
resource_name = "reverb"
hipass = 1.0

[sub_resource type="AudioEffectPanner" id="AudioEffectPanner_g28q7"]
resource_name = "pan"

[resource]
bus/1/name = &"RaytracedReverb"
bus/1/solo = false
bus/1/mute = false
bus/1/bypass_fx = false
bus/1/volume_db = 0.0
bus/1/send = &"Master"
bus/1/effect/0/effect = SubResource("AudioEffectReverb_j3pel")
bus/1/effect/0/enabled = true
bus/2/name = &"RaytracedAmbient"
bus/2/solo = false
bus/2/mute = false
bus/2/bypass_fx = false
bus/2/volume_db = 0.0
bus/2/send = &"Master"
bus/2/effect/0/effect = SubResource("AudioEffectPanner_g28q7")
bus/2/effect/0/enabled = true


================================================
FILE: icon.png.import
================================================
[remap]

importer="texture"
type="CompressedTexture2D"
uid="uid://cswkq85176f4o"
path="res://.godot/imported/icon.png-487276ed1e3a0c39cad0279d744ee560.ctex"
metadata={
"vram_texture": false
}

[deps]

source_file="res://icon.png"
dest_files=["res://.godot/imported/icon.png-487276ed1e3a0c39cad0279d744ee560.ctex"]

[params]

compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1


================================================
FILE: icon.svg.import
================================================
[remap]

importer="texture"
type="CompressedTexture2D"
uid="uid://dxvcb2moy11s4"
path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"
metadata={
"vram_texture": false
}

[deps]

source_file="res://icon.svg"
dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"]

[params]

compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false


================================================
FILE: project.godot
================================================
; Engine configuration file.
; It's best edited using the editor UI and not directly,
; since the parameters that go here are not all obvious.
;
; Format:
;   [section] ; section goes between []
;   param=value ; assign values to parameters

config_version=5

[animation]

compatibility/default_parent_skeleton_in_mesh_instance_3d=true

[application]

config/name="Raytraced Audio"
run/main_scene="uid://dgx3qrieojg3x"
config/features=PackedStringArray("4.6", "GL Compatibility")
config/icon="uid://cswkq85176f4o"

[audio]

general/3d_panning_strength=1.0

[editor_plugins]

enabled=PackedStringArray("res://addons/raytraced_audio/plugin.cfg")

[raytraced_audio]

reverb_bus=&"RaytracedReverb"
ambient_bus=&"RaytracedAmbient"

[rendering]

renderer/rendering_method="gl_compatibility"
renderer/rendering_method.mobile="gl_compatibility"
Download .txt
gitextract_3z3d8ftl/

├── .editorconfig
├── .gitattributes
├── .github/
│   └── FUNDING.yml
├── .gitignore
├── LICENSE
├── README.md
├── addons/
│   └── raytraced_audio/
│       ├── LICENSE
│       ├── README.md
│       ├── audio_ray.gd
│       ├── plugin.cfg
│       ├── plugin.gd
│       ├── raytraced_audio_listener.gd
│       └── raytraced_audio_player_3d.gd
├── default_bus_layout.tres
├── icon.png.import
├── icon.svg.import
└── project.godot
Condensed preview — 17 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (42K chars).
[
  {
    "path": ".editorconfig",
    "chars": 33,
    "preview": "root = true\n\n[*]\ncharset = utf-8\n"
  },
  {
    "path": ".gitattributes",
    "chars": 231,
    "preview": "# Normalize EOL for all files that Git considers text files.\n* text=auto eol=lf\n\n# Only include the adodns folder when d"
  },
  {
    "path": ".github/FUNDING.yml",
    "chars": 891,
    "preview": "# These are supported funding model platforms\n\ngithub: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [u"
  },
  {
    "path": ".gitignore",
    "chars": 65,
    "preview": "test_scene/\n*.uid\n\n# Godot 4+ specific ignores\n.godot/\n/android/\n"
  },
  {
    "path": "LICENSE",
    "chars": 1065,
    "preview": "MIT License\n\nCopyright (c) 2025 Tienne_k\n\nPermission is hereby granted, free of charge, to any person obtaining a copy o"
  },
  {
    "path": "README.md",
    "chars": 2125,
    "preview": "# raytraced-audio\nAdds procedural audio effects to Godot like echo, ambient outdoor sounds, and muffle.\n\n[Showcase Video"
  },
  {
    "path": "addons/raytraced_audio/LICENSE",
    "chars": 1065,
    "preview": "MIT License\n\nCopyright (c) 2025 Tienne_k\n\nPermission is hereby granted, free of charge, to any person obtaining a copy o"
  },
  {
    "path": "addons/raytraced_audio/README.md",
    "chars": 2057,
    "preview": "# raytraced-audio\nAdds procedural audio effects to Godot like echo, ambient outdoor sounds, and muffle.\n\nCheck out [this"
  },
  {
    "path": "addons/raytraced_audio/audio_ray.gd",
    "chars": 3748,
    "preview": "extends RayCast3D\n\n# dont you worry about this class, habibi\n# shhhhhh its okay\n# ( -  ͜ʖ -)☞(ʘ_ʘ; )\n\nvar cast_dist: flo"
  },
  {
    "path": "addons/raytraced_audio/plugin.cfg",
    "chars": 183,
    "preview": "[plugin]\n\nname=\"Raytraced Audio\"\ndescription=\"Adds procedural audio effects to Godot like echo, ambient outdoor sounds, "
  },
  {
    "path": "addons/raytraced_audio/plugin.gd",
    "chars": 2725,
    "preview": "@tool\nextends EditorPlugin\n\n\nfunc _enter_tree() -> void:\n\t_setup_settings()\n\n\t# For some reason, adding custom types lik"
  },
  {
    "path": "addons/raytraced_audio/raytraced_audio_listener.gd",
    "chars": 12695,
    "preview": "class_name RaytracedAudioListener extends AudioListener3D\n## 3D audio listener for raytraced audio\n##\n## [b]Audio buses["
  },
  {
    "path": "addons/raytraced_audio/raytraced_audio_player_3d.gd",
    "chars": 9250,
    "preview": "class_name RaytracedAudioPlayer3D extends AudioStreamPlayer3D\n## Audio stream player that allows for audio muffling\n## \n"
  },
  {
    "path": "default_bus_layout.tres",
    "chars": 748,
    "preview": "[gd_resource type=\"AudioBusLayout\" format=3 uid=\"uid://dfuwe48pov8j3\"]\n\n[sub_resource type=\"AudioEffectReverb\" id=\"Audio"
  },
  {
    "path": "icon.png.import",
    "chars": 746,
    "preview": "[remap]\n\nimporter=\"texture\"\ntype=\"CompressedTexture2D\"\nuid=\"uid://cswkq85176f4o\"\npath=\"res://.godot/imported/icon.png-48"
  },
  {
    "path": "icon.svg.import",
    "chars": 843,
    "preview": "[remap]\n\nimporter=\"texture\"\ntype=\"CompressedTexture2D\"\nuid=\"uid://dxvcb2moy11s4\"\npath=\"res://.godot/imported/icon.svg-21"
  },
  {
    "path": "project.godot",
    "chars": 837,
    "preview": "; Engine configuration file.\n; It's best edited using the editor UI and not directly,\n; since the parameters that go her"
  }
]

About this extraction

This page contains the full source code of the WhoStoleMyCoffee/raytraced-audio GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 17 files (38.4 KB), approximately 11.1k tokens. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!