SoundToScreen

Table of Contents

1. About

SoundToScreen shows where sounds come from. It may be useful for people who play without sound, with mono sound, or people with hearing impairment.

1.1. Features

  • Three types of sound: noise (harmless), level (doors, elevators), danger (threats).
  • Compatible with old saves.
  • Options to configure looks.

1.2. Implementation Notes

  • SoundToScreen won't work if UZDoom is launched with -nosound.

1.3. Inspiration

2. License

3. Options

OptionMenu st_Menu
{
  st_PlainTranslator

  Title "SoundToScreen"

  StaticText  "Position"
  Slider      "Horizontal", st_x_position, 0.0, 1.0, 0.01, 2
  Slider      "Vertical",   st_y_position, 0.0, 1.0, 0.01, 2

  StaticText  ""
  Slider      "Width",      st_x_radius, 20, 50, 1
  Slider      "Height",     st_y_radius, 20, 50, 1
  Slider      "Dot size",   st_dot_size,  1,  9, 2

  StaticText  ""
  Option      "On automap", st_on_automap, OnOff

  StaticText  ""
  StaticText  "Colors"
  ColorPicker "Base",   st_color_base
  ColorPicker "Noise",  st_color_noise
  ColorPicker "Level",  st_color_geometry
  ColorPicker "Danger", st_color_danger
}

AddOptionMenu OptionsMenu       { Submenu "$ST_MENU_NAME", st_Menu }
AddOptionMenu OptionsMenuSimple { Submenu "$ST_MENU_NAME", st_Menu }

PlainTranslator

[enu default]
ST_MENU_NAME  = "SoundToScreen \cj⚞";

[ru]
ST_DANGER = "Опасность";
user float st_x_position = 0.5;
user float st_y_position = 0.3;
user float st_x_radius = 30;
user float st_y_radius = 30;
user int   st_dot_size = 3;
user bool  st_on_automap = false;

user color st_color_base     = "000000"; // black
user color st_color_noise    = "FFFFFF"; // white
user color st_color_geometry = "00FF00"; // green
user color st_color_danger   = "FF0000"; // red

4. Code

GameInfo { EventHandlers = "st_EventHandler" }
version 4.13.3

#include "zscript/st_PlainTranslator.zs"

class st_EventHandler : StaticEventHandler
{
  // Collects sounds by searching for actors that make sounds and doors/elevators.
  override void worldTick()
  {
    if (!mIsInitialized)
    {
      if (level.mapName ~== "TITLEMAP") return;
      initialize();
    }

    if (players[consolePlayer].mo == NULL) return;

    mIterator.reinit();

    for (int type = Noise; type < SoundTypesCount; ++type)
      mColors[type] = mColorCvars[type].getString();

    for (int type = Noise; type < SoundTypesCount; ++type)
    {
      for (int i = 0; i < DOTS_COUNT; ++i)
        mDots[type][i] = 0;
    }

    let player = players[consolePlayer].mo;
    Thinker aThinker;
    while (aThinker = mIterator.next())
    {
      let anActor = Actor(aThinker);
      if (anActor != NULL)
      {
        if (anActor == player) continue;
        if (anActor is "Inventory" && Inventory(anActor).owner != NULL) continue;
        if (anActor.bMissile && anActor.target == player) continue;
        if (!anActor.isActorPlayingSound(CHAN_AUTO)) continue;
        double distance = anActor.distance3D(player);
        if (anActor.bBoss) distance = min(distance, MAX_DISTANCE / 2);
        if (distance > MAX_DISTANCE) continue;

        let type = ((anActor.bIsMonster && !anActor.bFriendly && anActor.health > 0)
                    || (anActor.bMissile && anActor.damage > 0)) ? Danger : Noise;

        let position = calculateActorScreenPosition(anActor);
        mDots[type][position] += distanceToDotLevel(distance);

        continue;
      }

      let aMover = Mover(aThinker);
      if (aMover != NULL)
      {
        Sector aSector = aMover.getSector();
        if (aSector.flags & Sector.SECF_SilentMove) continue;

        // Important order: ask if moving before remembering.
        bool isMoving = isMoving(aSector);
        remember(aSector);
        if (!isMoving) continue;

        vector3 playerRelative = player.posRelative(aSector);
        vector2 xy = aSector.centerSpot;
        double z = aMover is "MovingCeiling"
          ? aSector.centerCeiling()
          : aSector.centerFloor();
        vector3 soundSource = (xy.x, xy.y, z);
        vector3 diff = level.vec3Diff(soundSource, playerRelative);
        double distance = diff.length();
        if (distance > MAX_DISTANCE) continue;

        let position = calculateSectorScreenPosition(aSector);
        mDots[Geometry][position] += distanceToDotLevel(distance);
      }
    }
  }

  override void renderOverlay(RenderEvent event)
  {
    if (!mIsInitialized) return;
    if (players[consolePlayer].mo == NULL) return;
    if (automapActive && !mOnAutomapCvar.getInt())
    {
      resetUi();
      return;
    }

    vector2 center = (mXPositionCvar.getFloat() * Screen.getWidth(),
                      mYPositionCvar.getFloat() * Screen.getHeight());
    double radiusX = mXRadiusCvar.getFloat();
    double radiusY = mYRadiusCvar.getFloat();
    int    dotSize = mDotSizeCvar.getInt();
    Color  baseColor = mBaseColorCvar.getString();

    for (int i = 0; i < DOTS_COUNT; ++i)
      drawDot(center + makePosition(i, 0.0, radiusX, radiusY), baseColor, dotSize);

    if (level.mapTime < 2)
    {
      resetUi();
      return;
    }

    for (int type = Noise; type < SoundTypesCount; ++type)
    {
      for (int i = 0; i < DOTS_COUNT; ++i)
      {
        double diff = mDotsInterpolated[type][i] - mDots[type][i];
        mDotsInterpolated[type][i] -= diff * ((diff > 0) ? 0.2 : 0.4);
      }
    }

    // Fake sound level changes.
    double dotsVariance[SoundTypesCount][DOTS_COUNT];
    if (level.time % 3 == 0)
    {
      for (int type = Noise; type < SoundTypesCount; ++type)
      {
        for (int i = 0; i < DOTS_COUNT; ++i)
          dotsVariance[type][i] = frandom[SoundToScreen](-MIN_DOT_LEVEL, MIN_DOT_LEVEL);
      }
    }

    // Gaussian blur, sigma 1.
    static const double blurWeights[] =
    {
      0.0613595978134402  / 0.38774041331389975,
      0.24477019552960988 / 0.38774041331389975,
      1.0,
      0.24477019552960988 / 0.38774041331389975,
      0.0613595978134402  / 0.38774041331389975
    };

    double dotsBlurred[SoundTypesCount][DOTS_COUNT];
    for (int type = Noise; type < SoundTypesCount; ++type)
    {
      for (int i = 0; i < DOTS_COUNT; ++i)
      {
        double sum = 0;
        for (int j = -2; j <= 2; ++j)
        {
          int index = (i + j + DOTS_COUNT) % DOTS_COUNT;
          sum += mDotsInterpolated[type][index] * blurWeights[2 + j];

          if (mDotsInterpolated[type][index] > MIN_DOT_LEVEL)
            sum += dotsVariance[type][index];
        }
        dotsBlurred[type][i] = min(1.0, sum);
      }
    }

    for (int type = Noise; type < SoundTypesCount; ++type)
    {
      for (int i = 0; i < DOTS_COUNT; ++i)
      {
        double dotLevel = dotsBlurred[type][i];
        if (dotLevel > MIN_DOT_LEVEL)
        {
          drawDot(center + makePosition(i, dotLevel, radiusX, radiusY),
                  mColors[type],
                  dotSize);
        }
      }
    }
  }

  private ui void resetUi()
  {
    for (int type = Noise; type < SoundTypesCount; ++type)
      for (int i = 0; i < DOTS_COUNT; ++i) mDotsInterpolated[type][i] = 0;
  }

  private ui vector2 makePosition(int i, double dotLevel, double radiusX, double radiusY)
  {
    double angle = i * (360.0 / DOTS_COUNT);
    return ((1 + dotLevel) * radiusX * sin(angle),
           -(1 + dotLevel) * radiusY * cos(angle));
  }

  private ui void drawDot(vector2 position, Color aColor, int dotSize)
  {
    int halfSize = dotSize / 2;
    Screen.dim(aColor, 1.0,
               int(round(position.x)) - halfSize,
               int(round(position.y)) - halfSize,
               dotSize,
               dotSize);
  }

  private void initialize()
  {
    mIsInitialized = true;
    mIterator = ThinkerIterator.create("Thinker");

    PlayerInfo player = players[consolePlayer];
    mXPositionCvar    = Cvar.getCvar("st_x_position", player);
    mYPositionCvar    = Cvar.getCvar("st_y_position", player);
    mXRadiusCvar      = Cvar.getCvar("st_x_radius", player);
    mYRadiusCvar      = Cvar.getCvar("st_y_radius", player);
    mDotSizeCvar      = Cvar.getCvar("st_dot_size", player);

    mOnAutomapCvar    = Cvar.getCvar("st_on_automap", player);

    mBaseColorCvar        = Cvar.getCvar("st_color_base", player);
    mColorCvars[Noise]    = Cvar.getCvar("st_color_noise", player);
    mColorCvars[Geometry] = Cvar.getCvar("st_color_geometry", player);
    mColorCvars[Danger]   = Cvar.getCvar("st_color_danger", player);
  }

  private static int calculateActorScreenPosition(Actor target)
  {
    PlayerInfo player = players[consolePlayer];
    double angleToTarget = (player.mo.angle - player.mo.angleTo(target)) % 360.0;
    return int(round(angleToTarget / (360.0 / DOTS_COUNT))) % DOTS_COUNT;
  }

  private static int calculateSectorScreenPosition(sector aSector)
  {
    PlayerInfo player = players[consolePlayer];
    vector3 playerRelative = player.mo.posRelative(aSector);
    vector2 diff = aSector.centerSpot - playerRelative.xy;
    double angleToTarget = (player.mo.angle - atan2(diff.y, diff.x)) % 360.0;
    return int(round(angleToTarget / (360.0 / DOTS_COUNT))) % DOTS_COUNT;
  }

  // No way to easily query if a sector is moving, hence this workaround.
  private bool isMoving(Sector aSector)
  {
    let memory = st_SectorMemory(mSectorMemories.getIfExists(aSector.index()));
    return memory != NULL
        && memory.time == level.time - 1
        && (memory.floorHeight != aSector.centerFloor()
            || memory.ceilingHeight != aSector.centerCeiling());
  }

  private void remember(Sector aSector)
  {
    let memory = st_SectorMemory(mSectorMemories.getIfExists(aSector.index()));
    if (memory == NULL)
    {
      memory = new("st_SectorMemory");
      mSectorMemories.insert(aSector.index(), memory);
    }

    memory.time = level.time;
    memory.floorHeight   = aSector.centerFloor();
    memory.ceilingHeight = aSector.centerCeiling();
  }

  // Note: when changing this formula, adjust MAX_DISTANCE (reverse of this).
  private double distanceToDotLevel(double distance)
  {
    return min(1.0, 1.0 / max(distance * distance / DISTANCE_FACTOR, 1.0));
  }

  enum SoundType {Noise, Geometry, Danger, SoundTypesCount}
  const DOTS_COUNT = 32; // Should be divisible by 4 for clear base directions.
  const MIN_DOT_LEVEL = 0.05;
  const DISTANCE_FACTOR = 100000.0;
  const MAX_DISTANCE = sqrt(DISTANCE_FACTOR / MIN_DOT_LEVEL);

  private double mDots[SoundTypesCount][DOTS_COUNT];
  private ui double mDotsInterpolated[SoundTypesCount][DOTS_COUNT];
  private Color mColors[SoundTypesCount];
  private bool mIsInitialized;
  private ThinkerIterator mIterator;
  private Map<int, Object> mSectorMemories;

  private Cvar mXPositionCvar;
  private Cvar mYPositionCvar;
  private CVar mXRadiusCvar;
  private CVar mYRadiusCvar;
  private Cvar mDotSizeCvar;
  private Cvar mOnAutomapCvar;
  private Cvar mBaseColorCvar;
  private Cvar mColorCvars[SoundTypesCount];
}

class st_SectorMemory
{
  int time;
  double floorHeight;
  double ceilingHeight;
}

5. Test

GameInfo { EventHandlers = "stt_EventHandler" }
version 4.13.3

class stt_EventHandler : StaticEventHandler
{
  override void networkProcess(ConsoleEvent command)
  {
    if (command.name == "stt_spawn_imps")
    {
      int count = command.args[0];
      Console.printf("Spawning %d imps...", count);
      for (int i = 0; i < count; ++i)
        Actor.spawn("doomimp", players[consolePlayer].mo.pos + (50, 0, 0));
    }
  }
}
wait 2; map map01; wait 2; quit

Created: 2026-03-17 Tue 16:43