Skip to content

Conversation

@nixxquality
Copy link
Contributor

Summary

This pull request adds a Change attribute to the Renderer property on the Voice transmitter component.
The executed method will enable lipsync if the Renderer is valid.

Motivation & Context

Fixes: #201

Implementation Details

I had to make the Change method public, or it wouldn't be found:
2026-01-27_10-18-25
2026-01-27_10-18-37
2026-01-27_10-18-58

This matches the usage in Dresser:

public void OnManualChange( float a, float b )

The contents of the Change method are taken from the OnInternalEnabled function:

if ( Renderer.IsValid() && Renderer.Model.MorphCount > 0 )
{
morphs = new float[Renderer.Model.MorphCount];
}

With just that code, the lip sync would only work when you stopped talking and then started talking again, so I looked at this as well:
private unsafe void OnVoice( byte[] buffer )
{
if ( buffer.Length < 2 )
return;
if ( soundStream is null )
return;
VoiceManager.Uncompress( buffer, samples =>
{
if ( !sound.IsValid() )
{
sound = soundStream.Play();
sound.TargetMixer = TargetMixer;
sound.Distance = Distance;
sound.Falloff = Falloff;
sound.LipSync.Enabled = LipSync && Renderer.IsValid();
sound.IsVoice = true;
}
soundStream.WriteData( samples.Span );
LastPlayed = 0;
UpdateSound();
} );
}

(emphasis on line 417)

The current code seems to work in both instances. Feel free to test it with the sample project in the linked issue.

Checklist

  • Code follows existing style and conventions
  • No unnecessary formatting or unrelated changes
  • Public APIs are documented (if applicable)
  • Unit tests added where applicable and all passing
  • I’m okay with this PR being rejected or requested to change 🙂

Copilot AI review requested due to automatic review settings January 27, 2026 09:41
@nixxquality
Copy link
Contributor Author

badandbest pointed out in the Discord that the public keyword was required because of TypeLibrary restrictions.

I'm keeping it public to match Dresser's implementation for now, but if you'd like I can change it to be private with [Expose] instead.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request fixes an issue where lip sync does not start when the Renderer property is set on a Voice component after initialization. The fix adds a Change attribute to the Renderer property that triggers initialization logic when the renderer is updated at runtime.

Changes:

  • Added a Change attribute to the Renderer property to trigger a callback when the renderer is changed
  • Implemented the OnRendererChange public method to handle renderer changes by initializing the morphs array and enabling/disabling lip sync appropriately

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@nixxquality
Copy link
Contributor Author

One thing I forgot to mention: Perhaps the same code should also run when LipSync is changed between true and false?

If you'd like, I'll add another Change method which would do the same thing.

@handsomematt
Copy link
Member

Why [Change] over a standard C# property setter?

@nixxquality
Copy link
Contributor Author

Why [Change] over a standard C# property setter?

This is just my opinion, but I believe setters should be used for verification of inputs while side effects as a result of the value changing should be elsewhere. I really like the Change attribute because it fills that gap.
My way of verifying this was to check if Change was even used in the s&box code base at all, and that's where I saw the use in Dresser.

If you're saying that a setter would be following existing conventions, I could do that instead.

@lolleko
Copy link
Contributor

lolleko commented Jan 27, 2026

Also in favor of using a regular setter.
Change and MakeDirty obfuscate the code. I hate that we have them.

@nixxquality
Copy link
Contributor Author

Understood. I'm gonna look into that possible NRE as well.

@nixxquality
Copy link
Contributor Author

I went ahead and fixed the possible NRE in the constructor as well since that's where I took that piece of code from.
Here's a GameObject which triggers the NRE in current master on start:

[
  {
    "__guid": "81fafe3f-e2b5-4947-84f6-fa251cc22677",
    "__version": 2,
    "Flags": 0,
    "Name": "Object",
    "Position": "130.7224,13.61734,39.1015",
    "Rotation": "0,0,0,1",
    "Scale": "1,1,1",
    "Tags": "",
    "Enabled": true,
    "NetworkMode": 2,
    "NetworkFlags": 0,
    "NetworkOrphaned": 0,
    "NetworkTransmit": true,
    "OwnerTransfer": 1,
    "Components": [
      {
        "__type": "Sandbox.SkinnedModelRenderer",
        "__guid": "5eedc285-87d1-4436-9660-d99fc34932f8",
        "__enabled": true,
        "Flags": 0,
        "AnimationGraph": null,
        "BodyGroups": 18446744073709551615,
        "BoneMergeTarget": null,
        "CreateAttachments": false,
        "CreateBoneObjects": false,
        "LodOverride": null,
        "MaterialGroup": null,
        "MaterialOverride": null,
        "Materials": null,
        "Model": null,
        "Morphs": {},
        "OnComponentDestroy": null,
        "OnComponentDisabled": null,
        "OnComponentEnabled": null,
        "OnComponentFixedUpdate": null,
        "OnComponentStart": null,
        "OnComponentUpdate": null,
        "Parameters": {
          "bools": {},
          "ints": {},
          "floats": {},
          "vectors": {},
          "rotations": {}
        },
        "PlaybackRate": 1,
        "RenderOptions": {
          "GameLayer": true,
          "OverlayLayer": false,
          "BloomLayer": false,
          "AfterUILayer": false
        },
        "RenderType": "On",
        "Sequence": {
          "Name": null,
          "Looping": true,
          "Blending": false
        },
        "Tint": "1,1,1,1",
        "UseAnimGraph": true
      },
      {
        "__type": "Sandbox.Voice",
        "__guid": "98497791-71ab-49c8-af88-88b20934183c",
        "__enabled": true,
        "Flags": 0,
        "Distance": 15000,
        "Falloff": [
          {
            "x": 0,
            "y": 1,
            "in": 0,
            "out": -1.8,
            "mode": "Mirrored"
          },
          {
            "x": 0.05,
            "y": 0.22,
            "in": 3.5,
            "out": -3.5,
            "mode": "Mirrored"
          },
          {
            "x": 0.2,
            "y": 0.04,
            "in": 0.16,
            "out": -0.16,
            "mode": "Mirrored"
          },
          {
            "x": 1,
            "y": 0,
            "in": 0,
            "out": 0,
            "mode": "Mirrored"
          }
        ],
        "LipSync": true,
        "Loopback": false,
        "Mode": "AlwaysOn",
        "MorphScale": 3,
        "MorphSmoothTime": 0.1,
        "OnComponentDestroy": null,
        "OnComponentDisabled": null,
        "OnComponentEnabled": null,
        "OnComponentFixedUpdate": null,
        "OnComponentStart": null,
        "OnComponentUpdate": null,
        "PushToTalkInput": "voice",
        "Renderer": {
          "_type": "component",
          "component_id": "5eedc285-87d1-4436-9660-d99fc34932f8",
          "go": "81fafe3f-e2b5-4947-84f6-fa251cc22677",
          "component_type": "SkinnedModelRenderer"
        },
        "VoiceMixer": {
          "Name": "unknown",
          "Id": "00000000-0000-0000-0000-000000000000"
        },
        "Volume": 1,
        "WorldspacePlayback": true
      }
    ],
    "Children": []
  }
]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cannot late bind the Renderer on a Voice component

3 participants