diff --git a/network/network_chat_service.gd b/network/network_chat_service.gd index 4e5a432..75cdc81 100644 --- a/network/network_chat_service.gd +++ b/network/network_chat_service.gd @@ -556,7 +556,10 @@ func receive_chat_message(data: Dictionary) -> void: _apply_message(data) -func _apply_message(data: Dictionary) -> void: +func _apply_message( + data: Dictionary, + emit_live_signals: bool = true, +) -> void: if ( not NetworkChatProtocol.validate_message(data) or str(data["session_id"]) != _session.get_session_id() @@ -597,7 +600,7 @@ func _apply_message(data: Dictionary) -> void: _history.append(stored_message) while _history.size() > NetworkChatProtocol.MAX_HISTORY: _history.pop_front() - if _message_is_visible(stored_message): + if emit_live_signals and _message_is_visible(stored_message): message_received.emit(stored_message.duplicate(true)) if ( kind == NetworkChatProtocol.Kind.PLAYER @@ -653,7 +656,10 @@ func receive_chat_history(values: Array) -> void: _seen_messages.clear() for value: Variant in values: if NetworkChatProtocol.validate_message(value): - _apply_message(value) + # Late-join history belongs in the scrollback, but it is not a live + # event. In particular, do not recreate speech bubbles or replay + # animalese for every stored player message when joining a room. + _apply_message(value, false) history_replaced.emit(get_history()) diff --git a/tests/profile_multiplayer_validation.gd b/tests/profile_multiplayer_validation.gd index 44fbfe5..669c155 100644 --- a/tests/profile_multiplayer_validation.gd +++ b/tests/profile_multiplayer_validation.gd @@ -3,6 +3,7 @@ extends SceneTree const MainScene: PackedScene = preload("res://main/main.tscn") const AnimaleseVoiceType = preload("res://ui/animalese_voice.gd") const TEST_PORT: int = 18144 +const HOST_HISTORY_MESSAGE: String = "silent history replay" const HOST_VOICE_MESSAGE: String = "remote voice playback" @@ -11,6 +12,7 @@ func _initialize() -> void: func _run() -> void: + _validate_voice_distance_attenuation() var arguments: PackedStringArray = OS.get_cmdline_user_args() if arguments.has("host"): await _run_host() @@ -22,6 +24,24 @@ func _run() -> void: quit(1) +func _validate_voice_distance_attenuation() -> void: + assert(is_zero_approx( + ChatUI.animalese_volume_offset_db_for_distance(0.0) + )) + assert(is_zero_approx( + ChatUI.animalese_volume_offset_db_for_distance(4.0) + )) + var middle_distance_volume := ( + ChatUI.animalese_volume_offset_db_for_distance(14.0) + ) + assert(middle_distance_volume < 0.0) + assert(middle_distance_volume > -80.0) + assert(is_equal_approx( + ChatUI.animalese_volume_offset_db_for_distance(24.0), + -80.0, + )) + + func _run_host() -> void: var main: Node = await _create_initialized_main() var session := main.get_node("%NetworkSession") as NetworkSession @@ -51,6 +71,11 @@ func _run_host() -> void: var chat_service := main.get_node( "%NetworkChatService" ) as NetworkChatService + assert(chat_service.send_local_message( + HOST_HISTORY_MESSAGE, + profile.voice_id, + profile.sample_set_id, + )) var voice_messages: Array[Dictionary] = [] chat_service.message_received.connect(func(message: Dictionary) -> void: if str(message.get("body", "")) == "voice profile sync": @@ -124,6 +149,22 @@ func _run_client() -> void: assert(session.supports_server_capability( NetworkProtocol.APPEARANCE_PREVIEW_CAPABILITY )) + var chat_service := main.get_node( + "%NetworkChatService" + ) as NetworkChatService + var history_deadline: int = Time.get_ticks_msec() + 8000 + while Time.get_ticks_msec() < history_deadline: + if chat_service.get_history().any( + func(message: Dictionary) -> bool: + return str(message.get("body", "")) == HOST_HISTORY_MESSAGE + ): + break + await process_frame + assert(chat_service.get_history().any( + func(message: Dictionary) -> bool: + return str(message.get("body", "")) == HOST_HISTORY_MESSAGE + )) + assert(remote_voice_samples.is_empty()) var service := main.get_node( "%NetworkProfileService" ) as NetworkProfileService @@ -160,9 +201,6 @@ func _run_client() -> void: var player := main.get_node("%PlayerSpawnService").get_local_player() as Player assert(player.get_animalese_voice_id() == "deep") assert(player.get_animalese_sample_set_id() == "kim") - var chat_service := main.get_node( - "%NetworkChatService" - ) as NetworkChatService var host_voice_messages: Array[Dictionary] = [] for message: Dictionary in chat_service.get_history(): if str(message.get("body", "")) == HOST_VOICE_MESSAGE: diff --git a/ui/animalese_voice.gd b/ui/animalese_voice.gd index b180838..1b0fe09 100644 --- a/ui/animalese_voice.gd +++ b/ui/animalese_voice.gd @@ -48,6 +48,7 @@ func speak_text( voice_profile_id: String = VoiceProfilesType.DEFAULT_ID, characters_per_second: float = -1.0, sample_set_id: String = VoiceProfilesType.DEFAULT_SAMPLE_SET_ID, + volume_offset_db: float = 0.0, ) -> Tween: var speech_tween := tween_owner.create_tween() var resolved_characters_per_second := ( @@ -75,6 +76,7 @@ func speak_text( voice_key, voice_pitch, resolved_sample_set_id, + volume_offset_db, ) ) return speech_tween @@ -110,6 +112,7 @@ func _play_character( voice_key: String, voice_pitch: float, sample_set_id: String, + volume_offset_db: float, ) -> void: if _playback == null or character.strip_edges().is_empty(): return @@ -145,5 +148,10 @@ func _play_character( 0.72, 1.45, ) - _playback.play_stream(sample, 0.0, volume_db, pitch) + _playback.play_stream( + sample, + 0.0, + volume_db + volume_offset_db, + pitch, + ) character_sample_played.emit(sample_set_id, normalized) diff --git a/ui/chat_ui.gd b/ui/chat_ui.gd index da3bb59..601559e 100644 --- a/ui/chat_ui.gd +++ b/ui/chat_ui.gd @@ -26,6 +26,9 @@ const SPEECH_BUBBLE_WIDTH: float = 320.0 const SPEECH_POINTER_HALF_WIDTH: float = 12.0 const SPEECH_POINTER_HEIGHT: float = 14.0 const SPEECH_POINTER_OVERLAP: float = 3.0 +const ANIMALESE_FULL_VOLUME_DISTANCE: float = 4.0 +const ANIMALESE_SILENT_DISTANCE: float = 24.0 +const ANIMALESE_SILENT_VOLUME_DB: float = -80.0 const MOBILE_COMPACT_WIDTH: float = 620.0 const MOBILE_EXPANDED_WIDTH: float = 820.0 const MOBILE_COMPACT_HEIGHT: float = 220.0 @@ -1022,14 +1025,17 @@ func _show_speech_bubble( sample_set_id = VoiceProfilesType.sanitized_sample_set_id( speaker_avatar.get_animalese_sample_set_id() ) - _animalese_voice.speak_text( - _animalese_voice, - label.text, - str(peer_id), - voice_profile_id, - -1.0, - sample_set_id, - ) + var voice_volume_offset_db := _get_voice_volume_offset_db(speaker_avatar) + if voice_volume_offset_db > ANIMALESE_SILENT_VOLUME_DB: + _animalese_voice.speak_text( + _animalese_voice, + label.text, + str(peer_id), + voice_profile_id, + -1.0, + sample_set_id, + voice_volume_offset_db, + ) _speech[peer_id] = { "bubble": bubble, "pointer": pointer, @@ -1042,6 +1048,28 @@ func _show_speech_bubble( } +func _get_voice_volume_offset_db(speaker_avatar: Player) -> float: + if speaker_avatar == null or _player == null: + return ANIMALESE_SILENT_VOLUME_DB + return animalese_volume_offset_db_for_distance( + speaker_avatar.global_position.distance_to(_player.global_position) + ) + + +static func animalese_volume_offset_db_for_distance(distance: float) -> float: + if not is_finite(distance) or distance >= ANIMALESE_SILENT_DISTANCE: + return ANIMALESE_SILENT_VOLUME_DB + if distance <= ANIMALESE_FULL_VOLUME_DISTANCE: + return 0.0 + var distance_weight := inverse_lerp( + ANIMALESE_FULL_VOLUME_DISTANCE, + ANIMALESE_SILENT_DISTANCE, + distance, + ) + var amplitude := pow(1.0 - distance_weight, 2.0) + return maxf(linear_to_db(amplitude), ANIMALESE_SILENT_VOLUME_DB) + + func _on_history(messages: Array) -> void: for peer_id: int in _speech.keys(): var state: Dictionary = _speech[peer_id]