From 67daa8689e657424f80d2241d2f2b5166702cdb7 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Tue, 13 Oct 2026 23:54:46 -0700 Subject: [PATCH 1/3] Play the music through the synth's reverb, and in mono with mono STEREO FX MM2 plays all its DirectMusic through a port on the "Microsoft Synthesizer", opened by DMusicObject::InitPort (MMDMusicManager::Init, DMusicManager::Init, DMusicObject::Init): the menu music, a race's music with its Big Air motif on the same performance, and the city ambience segment. The port asks for DMUS_EFFECT_REVERB (the object's reverb flag, set by its constructor, and the synth's caps report the effect) and for AudManagerBase::IsStereo() + 1 audio channels, at AudManager::GetSamplesPerSecond (22050 Hz). MM2 never sets the reverb's parameters, so the synth's documented DMUS_WAVES_REVERB_PARAMS defaults apply: 0 dB in-gain, -10 dB mix, 1000 ms, high-frequency ratio 0.001. OpenMM2's music had no reverb (TinySoundFont has none) and stayed stereo with STEREO FX mono, which made it sound narrower than the original (GitHub issue #8). SynthReverb approximates that reverb. Waves' algorithm is proprietary, so the structure (inferred) follows OpenMPT's BSD-licensed emulation of the Waves Reverb DMO, which takes the same four parameters: four combs of 45 ms x 2^(n/4) coupled in pairs by golden-ratio allpass lattices, a two-tap loop low-pass for the high-frequency decay, and a power-preserving dry/wet split. It runs near the port's 22050 Hz, so its loop filter cuts where MM2's did. MusicEngine runs the soundtrack with its motif and the ambience through it, and folds both to mono when STEREO FX is mono; as MM2 reads the flag when a phase opens its port, MusicPlayer takes the setting when the menus or a race start their music. Measured on 30 s renders of every song: the channel correlation goes from 0.90-0.995 to 0.74-0.90 and side/mid from -12.6..-26.0 dB to -8.3..-12.5 dB; the reverb decays in 1.00 s at 250 Hz, 0.75 s at 4 kHz and 0.46 s at 8 kHz. mm2tool music gains --dry (no reverb, bit-identical to the old renders) and --mono, and prints the stereo image. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/audio.md | 17 +- docs/music.md | 146 +++++++++++++- docs/parity/audio.md | 15 +- docs/parity/mm2/audio.md | 12 +- src/app/Context.cpp | 7 +- src/audio/CMakeLists.txt | 1 + src/audio/Music.cpp | 47 ++++- src/audio/Music.h | 30 ++- src/audio/SynthReverb.cpp | 182 +++++++++++++++++ src/audio/SynthReverb.h | 114 +++++++++++ tests/audio/test_synth_reverb.cpp | 325 ++++++++++++++++++++++++++++++ tools/mm2tool/cmd_music.cpp | 32 ++- 12 files changed, 900 insertions(+), 28 deletions(-) create mode 100644 src/audio/SynthReverb.cpp create mode 100644 src/audio/SynthReverb.h create mode 100644 tests/audio/test_synth_reverb.cpp diff --git a/docs/audio.md b/docs/audio.md index 8811908..d8534a6 100644 --- a/docs/audio.md +++ b/docs/audio.md @@ -7,7 +7,13 @@ names the original function a behaviour was taken from; "inferred" marks what is still OpenMM2's reading and should be compared with the original game. The function-by-function audit is `docs/parity/audio.md`. -Music (DirectMusic) is separate: see `docs/music.md`. +Music (DirectMusic) is separate: see `docs/music.md`. That covers what MM2's +synth port does to the soundtrack, the Big Air motif and the city ambience +segment (`DMusicObject::InitPort`): the Microsoft Synthesizer's reverb is on +(`DMUS_EFFECT_REVERB`, with the default `DMUS_WAVES_REVERB_PARAMS`: 0 dB in, +-10 dB mix, 1000 ms, high-frequency ratio 0.001), which OpenMM2 approximates +after its synth (`audio/SynthReverb`; the Waves algorithm is proprietary, so +**inferred**), and the port is mono when STEREO FX is mono. ## Volume, pan and pitch units @@ -34,7 +40,8 @@ the near one at full level. The mixer attenuates the far channel linearly, so `audSound::SetPan`). `audObject::SetPan` clamps pans to -1..1 first (this matters for the rain's thunder, below). With STEREO FX off (`IsStereo` false) MM2 skips its pan calls, so every sound stays centred -(`Mixer::setStereo(false)`). +(`Mixer::setStereo(false)`), and the music and the ambience segment are mono +(`MusicPlayer::setStereo`, see `docs/music.md`). Pitch is a multiple of the sample rate. `AudSoundBase::SetFrequency` goes through `audObject::SetPitch`, which clamps the multiplier to 0..2, and @@ -211,7 +218,7 @@ a time. Ported from MM2 `Aud3DObject` and `Aud3DObjectManager`: | With the player's car in a room flagged underground the 3D manager's echo goes on with a 0.5 s delay (`Object3DManager::setTunnel`, the race's hook) | MM2 (`mmPlayer::Update`, `Aud3DObjectManager::EchoOn / EchoOff`) | | Every sound object turns its samples' echo on and off at the start of its update: car engines, surfaces and skids, sirens, the semi's beeper and air brake, the clutch, ambient traffic engines and horns, ambient sets, pedestrian voices. The player's horn echoes 0.05 s behind at 0.997 of its rate; everything else at the manager's delay. Losing the sound slot turns the echo off | MM2 (`vehCarAudio::UpdateAudio / EchoOn`, `vehPoliceCarAudio`, `vehSemiCarAudio`, `aiAmbientVehicleAudio`, `Aud3DAmbientObject`, `AudCreatureContainer`) | | The echo is a duplicate of the sample (an effect voice, outside the 32-voice limit) that replays the sample's play, stop, volume and frequency changes once they are as old as the delay, at 0.96 of the volume (DirectSound units: volume × 0.96 × 10000 - 10000); only the oldest of several changes that come due together applies. A play also moves the duplicate to where the sample is at that moment. Its pan follows at once, mirrored and reduced to a quarter | MM2 (`EchoEffect`, `AudSoundBase`, `audFX::EnablePCEcho`) | -| The music has no echo: `MMDMusicManager::EchoOn` is never called. The city's ambience segment stops underground and starts again outside | MM2 (`MMDMusicManager::UpdateAmbientSFX`) | +| The music has no echo: `MMDMusicManager::EchoOn` is never called (it has the synth port's own reverb everywhere, see `docs/music.md`). The city's ambience segment stops underground and starts again outside | MM2 (`MMDMusicManager::UpdateAmbientSFX`) | | When the SOUND FX slider moves MM2 sets every echo to its sound's volume × master at once; OpenMM2's echoes take the new volume with their next queued change | deviation | ## Creature voices @@ -240,7 +247,9 @@ a time. Ported from MM2 `Aud3DObject` and `Aud3DObjectManager`: ## Not implemented -* EAX. +* EAX. (The audio manager's EAX and reverb calls are empty in build 3393, + `AudManagerBase::SetReverbFlags` among them; the only reverb MM2 has is the + music synth's, see above.) * The in-race CD player (`mmCDPlayer`): with the game disc in a drive (`cdid.txt`, which SafeDisc requires at launch) MM2 sets `hasMusicCD` and the player's keys do nothing, so it never works in the retail game; MM2 diff --git a/docs/music.md b/docs/music.md index 34fc183..d71481a 100644 --- a/docs/music.md +++ b/docs/music.md @@ -10,8 +10,9 @@ instruments from DLS collections (`.dls`). All of it is in `aud/dmusic/` in Code: `src/audio/Music.{h,cpp}` (tables, loader, engine, threaded player), `src/audio/MusicDirector.{h,cpp}` (MM2's rules for when the music changes), `src/audio/MusicMotif.{h,c}` (motif support, built into the dmusic library), -the dmusic section of `cmake/Dependencies.cmake`. Tools: -`mm2tool musicinfo ` and `mm2tool music ...`. +`src/audio/SynthReverb.{h,cpp}` (the synth port's reverb), the dmusic +section of `cmake/Dependencies.cmake`. Tools: `mm2tool musicinfo ` +and `mm2tool music ...`. ## What the game data says @@ -75,6 +76,7 @@ Commands are applied in the order they were issued. | `setState(state)` | without a timing: menus, pause and results immediately, the rest on the next measure | | `triggerBigAir()` | the motif from the next beat of the soundtrack, played twice, following its tempo and chord | | `setAmbience("london" / "sf" / "underground" / "")` | city ambience on its own stream | +| `setStereo(stereo)` | STEREO FX (not mono), taken by the next `playMenu` / `startRace` (see [the synth port](#the-synth-port-reverb-and-channels)) | | `stop()` | silence | The original's "Music/City Volume" option controlled the DirectMusic buffer, @@ -129,6 +131,118 @@ Notes on the port: pattern and variation choice. `$OPENMM2_MUSIC_SEED` fixes the seed for reproducible renders. +## The synth port: reverb and channels + +Every DirectMusic sound MM2 makes goes through a port on the software synth +named "Microsoft Synthesizer" (`DMusicObject::FindMSSoftWareSynth`), opened by +`DMusicObject::InitPort` (from `MMDMusicManager::Init` through +`DMusicManager::Init` and `DMusicObject::Init`). Each phase of the game makes +its own music manager and so its own port: the menus +(`mmInterface::PlayUIMusic`, or `AudioOptions::ToggleMusic` when the music is +turned on), a race's music (`mmSingleRaceMusicData` / +`mmSingleRoamMusicData::LoadMusic`) or, with music off, its city ambience +(`mmGameMusicData::LoadAmbientSFX`); `EndPhase` deletes it. The Big Air motif +is a secondary segment on the soundtrack's performance, so it shares the +soundtrack's port. The port's `DMUS_PORTPARAMS`: + +| Parameter | MM2 (`DMusicObject::InitPort`) | OpenMM2 | +|-----------|--------------------------------|---------| +| channel groups | 2 (32 pchannels) | dmusic's channels | +| voices | 41 (`MMDMusicManager::Init`), when the port's caps give a limit | no limit (TinySoundFont adds voices as needed) | +| audio channels | `AudManagerBase::IsStereo()` + 1: one with STEREO FX set to mono | stereo, or both channels (L + R) / 2 when mono | +| sample rate | `AudManager::GetSamplesPerSecond`: 22050 Hz (its constructor's value; nothing changes it) | the mixer's rate (48 kHz); the reverb runs near 22050 Hz | +| effects | `DMUS_EFFECT_REVERB` when the object's reverb flag (+0x18, set to 1 by the constructor) is on and the port's caps report the effect, which the Microsoft Synthesizer's do | `SynthReverb` on the music and on the ambience | + +**Mono.** `AudioOptions::SetStereoFX` maps STEREO FX 0 (mono) to a clear +stereo flag and 1 (stereo) and 2 (surround) to a set one +(`AudManagerBase::SetStereoFlag`), so only mono gives a one-channel port. +`MusicPlayer::setStereo` follows the setting and the engine takes it when the +menus or a race start their music (`playMenu`, `startRace`), as MM2 reads it +when it opens the port: a change in the options is heard from the next phase. +The mono port played through a mono DirectSound buffer, which sounds the same +in both speakers; how the synth folds its panned voices into one channel is +not known, so OpenMM2 uses (L + R) / 2 (**inferred**). + +**Reverb parameters.** MM2 never sets the reverb's parameters: there is no +`IKsControl` property call on its port (and the audio manager's EAX and +reverb calls, `AudManagerBase::SetReverbFlags` among them, are empty). So the +synth's defaults apply, `DMUS_WAVES_REVERB_PARAMS` as the DirectX 7 +documentation gives them +([Microsoft Learn: DMUS_WAVES_REVERB_PARAMS](https://learn.microsoft.com/en-us/previous-versions/dd551316(v=vs.85)), +header `dmusicc.h`): + +| Member | Default | Meaning | +|--------|---------|---------| +| `fInGain` | 0 dB | input gain | +| `fReverbMix` | -10 dB | 0 dB is 100% reverb with no direct sound, lower is drier; "the coefficients are calculated so that the overall output level stays approximately constant, regardless of the amount of reverb mix" | +| `fReverbTime` | 1000 ms | decay time | +| `fHighFreqRTRatio` | 0.001 | the high frequencies' decay time over `fReverbTime` (1000 ms and 0.1 would be 100 ms) | + +The same page says the reverb is Waves' TrueVerb, licensed to Microsoft as +"SimpleVerb", and that from DirectX 8 music reverb is a DMO (the Waves Reverb +DMO, `DSFXWavesReverb`, with the same four parameters but a default mix of +0 dB). Which of the two a DirectX 7 style port gets on a newer DirectX +runtime, and with which mix, is not known; OpenMM2 uses the DirectX 7 +values MM2 was written against (**inferred**). Wine's dmsynth turns its +FluidSynth reverb on for `DMUS_EFFECT_REVERB` and sends every channel to it +at the General MIDI default level (CC91 = 40, about -10 dB), which comes to +much the same amount of reverb. + +**Reverb algorithm** (`audio/SynthReverb.cpp`, **inferred**: the Waves +algorithm is proprietary). The structure follows OpenMPT's emulation of the +Waves Reverb DMO (`soundlib/plugins/dmo/WavesReverb.cpp`, BSD-3-Clause): + +* four comb delays of 45 ms × 2^(n/4) (45, 53.5, 63.6, 75.7 ms), in two + pairs; each pair is cross-coupled by a two-channel allpass lattice + (coefficient 0.618, the inverse golden ratio) that takes 11.5% of the + pair's delays. Left feeds combs 0 and 3, right combs 1 and 2 (comb 2 + inverted); the output is combs 0 + 2 (left) and 1 + 3 (right), so a mono + input comes out decorrelated; +* each loop's gain makes the tail fall 60 dB in `fReverbTime`; a two-tap + low-pass in each loop takes the highest frequencies down in + `fReverbTime × fHighFreqRTRatio` (with 0.001 it averages two samples, a + zero at the reverb's Nyquist frequency); +* `fReverbMix` is the reverb's share of the power: direct sound × + √(1 − 10^(mix/10)), reverb input × √(10^(mix/10)) × a normalisation of the + loops' energy; both × `fInGain`. At the defaults the direct sound is at + 0.949 (-0.46 dB). + +The loop filter acts at the reverb's own Nyquist frequency, and MM2's synth +port ran at 22050 Hz, so the reverb runs at the output rate divided by the +whole number nearest to output / 22050 (24 kHz at 48 kHz, 22050 Hz at +44.1 kHz): the input is averaged over those frames and the reverb's output +interpolated linearly between its samples. Its loop values are flushed to +zero below 1e-20 (no denormals; a tail ends in exact silence), and input that +is not a finite number is fed as silence. + +Measured from an impulse at 48 kHz (`tests/audio/test_synth_reverb.cpp` +measures the same way; T30 from the Schroeder integral per band): 1.00 s at +250 Hz, 0.92 s at 2 kHz, 0.75 s at 4 kHz, 0.46 s at 8 kHz; the first +reflection after 32 ms; left and right uncorrelated. + +Effect on the music (30 s renders with `OPENMM2_MUSIC_SEED=1`, `mm2tool +music --wav` with and without `--dry`): the correlation of the two channels +drops from 0.90-0.995 to 0.74-0.90, the side (L − R) to mid (L + R) energy +from -12.6..-26.0 dB to -8.3..-12.5 dB, with the RMS level up by at most +0.7 dB. Samples over full scale stay rare: none in nine of the eleven +renders, 2 → 0 in cruise 1 and 1 → 23 (of 2.9 million) in cruise 3: + +| Render | Correlation dry → reverb | Side/mid dry → reverb | +|--------|--------------------------|-----------------------| +| race 0 (Enemy) | 0.898 → 0.744 | -12.6 → -8.3 dB | +| race 1 (Bullet) | 0.973 → 0.900 | -18.4 → -12.5 dB | +| race 2 (Duck) | 0.899 → 0.776 | -12.7 → -9.0 dB | +| race 3 (London) | 0.935 → 0.779 | -14.6 → -8.9 dB | +| race 4 (PimpHand) | 0.995 → 0.852 | -26.0 → -10.9 dB | +| cruise 0 (SunRoof) | 0.988 → 0.868 | -22.3 → -11.4 dB | +| cruise 1 (TacoLoco) | 0.935 → 0.829 | -14.6 → -10.3 dB | +| cruise 2 (LeTigre) | 0.942 → 0.809 | -15.2 → -9.8 dB | +| cruise 3 (BackOff) | 0.928 → 0.820 | -14.3 → -9.6 dB | +| menu (UI) | 0.980 → 0.816 | -20.0 → -9.9 dB | +| SF ambience | 0.961 → 0.832 | -16.9 → -10.3 dB | + +The reverb costs about 0.05% of a core per stream. + ## dmusic changes The upstream sources are patched at configure time. Each patch is checked, so @@ -171,12 +285,15 @@ Two more fixes live in `MusicMotif.c`: * **Synthesizer.** dmusic converts DLS instruments to TinySoundFont. Articulations that SoundFont cannot express (some scaled DLS connection - blocks) are dropped, and there is no reverb or chorus, which the Microsoft - software synthesizer applied. Timbre and envelopes are close but not - identical. + blocks) are dropped. Timbre and envelopes are close but not identical. The + synth renders at the mixer's rate (48 kHz) where MM2's port ran at + 22050 Hz, and has no 41-voice limit (MM2's port would steal voices past + it). The Microsoft Synthesizer's reverb is approximated after the synth + (above); MM2 asked for no chorus (`DMUS_EFFECT_CHORUS` is not set). * **Level.** The synth output is scaled by 0.5, which keeps every song below - full scale (peaks of -0.6 dBFS or lower across all states). The original's - absolute music level relative to effects is not known. + full scale (peaks of -0.6 dBFS or lower across all states in 8 s renders; + over 30 s a handful of samples reach it, see the reverb's measurements). + The original's absolute music level relative to effects is not known. * **Pattern selection.** dmusic follows DirectX 7-style random selection and never picks one-measure groove patterns (a rule added upstream for Gothic). For MM2 that skips `LondonIntro` and `VanillaIntro` at groove 1, so the @@ -201,3 +318,18 @@ Two more fixes live in `MusicMotif.c`: * `tests/audio/test_music.cpp` covers table parsing, loading, audible output, the motif, silence after stop, the default-chord fix, streaming through the mixer and the `MusicDirector` rules. +* `tests/audio/test_synth_reverb.cpp` covers the reverb: the documented + defaults and dry gain, the internal rate, the decay time at 250 Hz within + 10% at 48, 44.1 and 22.05 kHz (and for 0.5 s and 2 s), the highs decaying + faster (and not with a ratio of 0.999), decorrelated channels, the direct + sound unchanged with the effect off or the driest mix, `fInGain`, block + sizes, silence (and a tail ending in exact zeros, no denormals), no NaN from + bad input or extreme parameters; with game data, a wider race song and a + mono engine and player. +* `mm2tool music ... --dry` renders without the reverb (bit-identical to + OpenMM2 0.4.2's renders), `--mono` as with STEREO FX mono; every render + prints the channels' correlation and side/mid ratio. +* A General MIDI fallback for systems without `gm.dls` (FluidR3_GM.sf2 and + the like): dmusic reads only DLS collections, so it would take a dmusic + patch to load SoundFont presets for the GM references; since no + soundtrack note plays on a GM instrument (above), it is not done (open). diff --git a/docs/parity/audio.md b/docs/parity/audio.md index f5f30b0..68d9c0d 100644 --- a/docs/parity/audio.md +++ b/docs/parity/audio.md @@ -4,16 +4,18 @@ Audited against MM2Recomp (midtown2.exe build 3393) on 2026-10-07; second pass (tunnel echo, world-owned ambient objects, cable cars, pedestrian voices, the race-side hooks) on 2026-10-08. -Summary (after the second pass): 265 table rows (a row may cover several -related functions); verified 69, fixed 155, deviation 11, inferred 10, +Summary (after the second pass): 268 table rows (a row may cover several +related functions); verified 69, fixed 157, deviation 11, inferred 11, open 0, openmm2 20. (The police explosion repeat and the ambient traffic -audio were closed after the ai-vehicles merge.) +audio were closed after the ai-vehicles merge; the music synth's reverb and +mono port, GitHub issue #8, were added later.) Scope: `src/audio/game/*` (P, including `PedAudio.*` from the second pass), `src/audio/Mixer.*`, `Music*`, `MusicDirector.*`, `MusicMotif.*` (M), `src/audio/SoundBank.*`, `Wav.*` (F), plus the files added by the audit: `src/audio/EchoEffect.*` (the tunnel echo's duplicate buffer), `src/audio/AngelRandom.*` (MM2's random numbers), `AngelUnits.*` (volume / pan units, moved out of -`game/AudioTables`) and `TextFields.*` (MM2's fgets / strtok / atof reading). +`game/AudioTables`) and `TextFields.*` (MM2's fgets / strtok / atof reading), +and later `SynthReverb.*` (the music synth port's reverb). Behaviour is described in `docs/audio.md` and `docs/music.md`. Cross-cutting findings that changed many rows: @@ -321,7 +323,10 @@ Cross-cutting findings that changed many rows: | `MusicEngine::triggerMotif` | `DMusicObject::PlayMotif`, `SegmentWrapper::Play` | fixed | a jump while the motif still plays does nothing (SegmentWrapper::Play returns while playing); SetRepeats(1) | | `MusicEngine::startMotif / renderMotif`, `MusicMotif.c` | DirectMusic secondary segments | inferred | dmusic has no secondary segments or motifs | | `MusicEngine::setAmbience` | `mmGameMusicData::LoadAmbientSFX`, `MMDMusicManager::UpdateAmbientSFX` | fixed | plays the city segment; the race now stops it ("") on entering a tunnel (audio flag 0x80, StopSegment(0)) and starts it again outside (PlaySegment). MM2 does this only in its ambient mode (music off), as one DirectMusic object plays either; OpenMM2 plays the ambience on its own stream. "underground" is a tool option MM2 never uses | -| `MusicEngine::playSegment / preload / loadMotifStyle / render / stopAll`, `toString` | — | openmm2 | tools and dmusic glue | +| `MusicEngine::playSegment / preload / loadMotifStyle / stopAll`, `toString` | — | openmm2 | tools and dmusic glue | +| `MusicEngine::render` | `DMusicObject::InitPort` (DMUS_EFFECT_REVERB, the audio channels) | fixed | found after the audit (GitHub issue #8): the soundtrack with its motif and the ambience segment now go through the synth port's reverb (`SynthReverb`), and are folded to mono with STEREO FX mono; they had no reverb and were always stereo. The fold to (L + R) / 2 is inferred | +| `MusicEngine::setStereo / setReverb`, `MusicPlayer::setStereo` (taken by `playMenu`, `startRace`, `playSegment`) | `DMusicObject::InitPort` (`AudManagerBase::IsStereo() + 1` channels), `EndPhase` | fixed | the setting is read when a phase opens its port (the menu music, a race's music or ambience), so a change is heard from the next phase; `setReverb(false)` is a tool option (`mm2tool music --dry`) | +| `SynthReverb` (`src/audio/SynthReverb.*`) | the Microsoft Synthesizer's reverb, `DMUS_WAVES_REVERB_PARAMS` | inferred | MM2 sets no parameters, so the documented DirectX 7 defaults (0 dB, -10 dB mix, 1000 ms, 0.001); the Waves algorithm is proprietary, so the structure follows OpenMPT's Waves Reverb DMO emulation, run near the port's 22050 Hz (docs/music.md) | | `MusicPlayer::*` | — | openmm2 | threading, ring buffers | | `MusicPlayer::startRace` | `mmSingleRaceMusicData::LoadMusic` | fixed | pickSong | diff --git a/docs/parity/mm2/audio.md b/docs/parity/mm2/audio.md index 623e34e..319a59a 100644 --- a/docs/parity/mm2/audio.md +++ b/docs/parity/mm2/audio.md @@ -3,7 +3,7 @@ Audited from MM2Recomp (midtown2.exe build 3393) on 2026-10-08. Summary: 1053 reachable functions in 70 classes and the global namespace; -ported 625 (of which newly ported 21), replaced 265, not needed 163, open 0. +ported 626 (of which newly ported 22), replaced 264, not needed 163, open 0. The first audit ([../audio.md](../audio.md)) went from OpenMM2's audio code to MM2. This record goes the other way: every function of MM2's audio @@ -46,6 +46,13 @@ What this audit found and ported: music again (`mmGame::Reset` → `StartMusic`). * **The old "Volume Divisor" engine tables** are read like `ParseCSVBufferOld` instead of rejected. +* **The music synth's port** (found later, GitHub issue #8): + `DMusicObject::InitPort` opens the Microsoft Synthesizer with + `DMUS_EFFECT_REVERB` and as many audio channels as `IsStereo` gives. + OpenMM2's music had no reverb and was stereo even with STEREO FX mono; it + now has an approximation of the synth's default reverb + (`audio/SynthReverb`) on the soundtrack, the motif and the ambience + segment, and mono music with mono STEREO FX. Found to be dead in MM2 (and so not needed): the in-race CD player (inert with the retail disc in the drive), MIDI, the streamed playlist music, @@ -378,7 +385,8 @@ AudStreamingMusic is an older streamed-playlist player that never plays. | `MMDMusicManager::MMDMusicManager`, `MMDMusicManager::~MMDMusicManager` | replaced | `app/Context.cpp` `Context::music` (the MusicPlayer) | Creation and teardown. | | `DMusicObject::SegmentSwitch`, `DMusicObject::StopSegment`, `DMusicObject::PlayMotif`, `SegmentWrapper::Play` | ported | `MusicDirector::segmentSwitch / autoTransition`, `MusicEngine::setState / triggerMotif` | First audit (both SegmentSwitch overloads). | | `DMusicObject::PlaySegment`, `DMusicObject::OpenSegmentFile`, `DMusicObject::IsPlaying`, `SegmentWrapper::IsPlaying`, `SegmentWrapper::Stop`, `SegmentWrapper::OpenSegmentFile` | ported | `MusicEngine` (`transitionTo`, `setState`), `MusicLibrary` | Starting a segment at once (StartMusic, ToggleMusic, PlayUIMusic, the ambience), loading it, whether it plays. | -| `DMusicObject::Activate`, `DMusicObject::AllocateMotifs`, `DMusicObject::AssignPChannelBlocks`, `DMusicObject::CleanUpSegmentWrappers`, `DMusicObject::CreateComposer`, `DMusicObject::DMusicObject`, `DMusicObject::FindMSSoftWareSynth`, `DMusicObject::GetDMusicWaveBuffer`, `DMusicObject::HandleNotifications`, `DMusicObject::Init`, `DMusicObject::InitLoader`, `DMusicObject::InitNotificationThread`, `DMusicObject::InitPerformance`, `DMusicObject::InitPort`, `DMusicObject::LoadBand`, `DMusicObject::LoadMotif`, `DMusicObject::PlayBand`, `DMusicObject::ScanDirectory`, `DMusicObject::SetSearchDirectory`, `DMusicObject::~DMusicObject`, `SegmentWrapper::SegmentWrapper`, `SegmentWrapper::CleanUp`, `SegmentWrapper::LoadBand`, `SegmentWrapper::LoadSegmentBands`, `SegmentWrapper::UnloadSegmentBands`, `SegmentWrapper::PlayBand`, `SegmentWrapper::SetLoaderPtr`, `SegmentWrapper::SetPerformancePtr`, `SegmentWrapper::~SegmentWrapper`, `DMusicManager::DMusicManager`, `DMusicManager::Init`, `DMusicManager::Activate`, `DMusicManager::GetDMusicObjectPtr`, `DMusicManager::~DMusicManager`, `DMusicWaveBuffer::Create`, `DMusicWaveBuffer::DMusicWaveBuffer`, `DMusicWaveBuffer::~DMusicWaveBuffer` | replaced | `audio/Music.cpp` (`MusicEngine`, `MusicPlayer`, `MusicLibrary`), `MusicMotif.c`, the dmusic library | DirectMusic's performance, port, software synth, loader, bands, composer and notification thread. DMusicManager::Activate(0/1) is the CD player's way of pausing the music (never in retail). | +| `DMusicObject::Activate`, `DMusicObject::AllocateMotifs`, `DMusicObject::AssignPChannelBlocks`, `DMusicObject::CleanUpSegmentWrappers`, `DMusicObject::CreateComposer`, `DMusicObject::DMusicObject`, `DMusicObject::FindMSSoftWareSynth`, `DMusicObject::GetDMusicWaveBuffer`, `DMusicObject::HandleNotifications`, `DMusicObject::Init`, `DMusicObject::InitLoader`, `DMusicObject::InitNotificationThread`, `DMusicObject::InitPerformance`, `DMusicObject::LoadBand`, `DMusicObject::LoadMotif`, `DMusicObject::PlayBand`, `DMusicObject::ScanDirectory`, `DMusicObject::SetSearchDirectory`, `DMusicObject::~DMusicObject`, `SegmentWrapper::SegmentWrapper`, `SegmentWrapper::CleanUp`, `SegmentWrapper::LoadBand`, `SegmentWrapper::LoadSegmentBands`, `SegmentWrapper::UnloadSegmentBands`, `SegmentWrapper::PlayBand`, `SegmentWrapper::SetLoaderPtr`, `SegmentWrapper::SetPerformancePtr`, `SegmentWrapper::~SegmentWrapper`, `DMusicManager::DMusicManager`, `DMusicManager::Init`, `DMusicManager::Activate`, `DMusicManager::GetDMusicObjectPtr`, `DMusicManager::~DMusicManager`, `DMusicWaveBuffer::Create`, `DMusicWaveBuffer::DMusicWaveBuffer`, `DMusicWaveBuffer::~DMusicWaveBuffer` | replaced | `audio/Music.cpp` (`MusicEngine`, `MusicPlayer`, `MusicLibrary`), `MusicMotif.c`, the dmusic library | DirectMusic's performance, port, software synth, loader, bands, composer and notification thread. DMusicManager::Activate(0/1) is the CD player's way of pausing the music (never in retail). | +| `DMusicObject::InitPort` | ported (new) | `audio/SynthReverb.cpp` `SynthReverb`, `MusicEngine::render / setStereo`, `MusicPlayer::setStereo` (latched by `playMenu` / `startRace`), `app/Context.cpp` | Opens the port FindMSSoftWareSynth found ("Microsoft Synthesizer") with DMUS_PORTPARAMS: valid flags 0x2e (channel groups, audio channels, sample rate, effects), 0x2f with voices when the caps give a limit; 2 channel groups; MMDMusicManager::Init's 41 voices; AudManagerBase::IsStereo() + 1 audio channels; AudManager::GetSamplesPerSecond (22050 Hz); DMUS_EFFECT_REVERB when the object's flag at +0x18 (1 from the constructor) is set and the caps' effect flags have it. Then the DirectSound buffer in the port's format (DMusicWaveBuffer::Create), Activate, AddPort, the pchannel blocks. Every DirectMusic manager MM2 makes goes through it: the menu music, a race's music (with the Big Air motif on the same performance) and the ambience segment (LoadAmbientSFX); EndPhase deletes the manager, so each phase opens its port anew. **Reverb:** MM2 sets no reverb parameters (no IKsControl property call; AudManagerBase::SetReverbFlags is empty), so the synth's documented DMUS_WAVES_REVERB_PARAMS defaults apply (0 dB in-gain, -10 dB mix, 1000 ms, 0.001 high-frequency ratio); OpenMM2 had no reverb and now applies `SynthReverb` with those values to the music and ambience streams. The Waves algorithm is proprietary: the structure follows OpenMPT's Waves Reverb DMO emulation (**inferred**, docs/music.md). **Channels:** STEREO FX mono (AudioOptions::SetStereoFX clears the 0x40 flag) made a one-channel port; OpenMM2's music was always stereo and is now folded to (L + R) / 2 (the fold **inferred**), taken when the menus or a race start their music. **Deviations:** the synth renders at the mixer's rate instead of 22050 Hz (the reverb runs near 22050 Hz), and dmusic has no 41-voice limit. Tests `tests/audio/test_synth_reverb.cpp`. | | `DMusicManager::SetVolume`, `DMusicWaveBuffer::SetVolume` | ported | `Mixer::setBusVolume(Bus::Music)` | The MUSIC slider's log curve (first audit). | | `DMusicManager::SetPan`, `DMusicWaveBuffer::SetPan` | replaced | `Mixer::setBalance` | The BALANCE slider on the music buffer; OpenMM2's balance applies to the whole output. | | `AudStreamingMusic::AudStreamingMusic`, `AudStreamingMusic::StreamingMusicReleaseControl`, `AudStreamingMusic::StreamingMusicUpdate`, `AudStreamingMusic::~AudStreamingMusic`, `AudStreamingMusic::`scalar_deleting_destructor'` | not needed | - | A playlist player (`aud/playlist`, `aud/music`) left from older Angel games: RestartAudio makes one, but nothing calls its Init or Play, so it has no tracks and its update does nothing. | diff --git a/src/app/Context.cpp b/src/app/Context.cpp index 8c07069..a471599 100644 --- a/src/app/Context.cpp +++ b/src/app/Context.cpp @@ -41,13 +41,18 @@ void Context::applyAudioSettings() { mixer->setBusVolume(audio::Bus::Music, music ? settings.musicVolume : 0.0f); mixer->setBalance(settings.balance); // AudioOptions::SetStereoFX: mono centres every voice; surround only adds - // an EAX/3D flag, so it plays as stereo. + // an EAX/3D flag, so it plays as stereo. Mono also makes the music mono + // (AudManagerBase::IsStereo sizes DirectMusic's port), from the next time + // the menus or a race start their music. mixer->setStereo(settings.stereoFx != 0); + if (m_music) + m_music->setStereo(settings.stereoFx != 0); } audio::MusicPlayer* Context::music() { if (!m_music && game && mixer) { m_music = std::make_unique(game->vfs, mixer->sampleRate()); + m_music->setStereo(settings.stereoFx != 0); if (!m_music->ok()) { log::warn("music: soundtrack unavailable"); } else { diff --git a/src/audio/CMakeLists.txt b/src/audio/CMakeLists.txt index fcaef44..c524b91 100644 --- a/src/audio/CMakeLists.txt +++ b/src/audio/CMakeLists.txt @@ -5,6 +5,7 @@ add_library(mm2_audio STATIC AngelRandom.cpp AngelUnits.cpp EchoEffect.cpp + SynthReverb.cpp Wav.cpp Mixer.cpp SoundBank.cpp diff --git a/src/audio/Music.cpp b/src/audio/Music.cpp index 79ca66f..b3a125d 100644 --- a/src/audio/Music.cpp +++ b/src/audio/Music.cpp @@ -327,7 +327,8 @@ const char* toString(MusicState s) { } MusicEngine::MusicEngine(std::shared_ptr library, MusicTables tables, int sampleRate) - : m_library(std::move(library)), m_tables(std::move(tables)), m_rate(sampleRate) { + : m_library(std::move(library)), m_tables(std::move(tables)), m_rate(sampleRate), + m_musicReverb(sampleRate), m_ambienceReverb(sampleRate) { m_music = createPerformance(m_rate); m_motif = createPerformance(m_rate); m_ambience = createPerformance(m_rate); @@ -560,6 +561,23 @@ void MusicEngine::stopAll() { m_state = MusicState::Silent; } +void MusicEngine::setReverb(bool on) { + m_musicReverb.setEnabled(on); + m_ambienceReverb.setEnabled(on); +} + +namespace { + +// A mono synth port (DMusicObject::InitPort with one audio channel) played +// through a mono DirectSound buffer, which sounds at the same level in both +// speakers. The fold of the synth's stereo voices into one channel is inferred. +void foldToMono(float* stereo, int frames) { + for (std::size_t i = 0, n = static_cast(frames) * 2; i < n; i += 2) + stereo[i] = stereo[i + 1] = (stereo[i] + stereo[i + 1]) * 0.5f; +} + +} // namespace + void MusicEngine::render(float* music, float* ambience, int frames) { const std::size_t n = static_cast(frames) * 2; if (music) { @@ -567,12 +585,23 @@ void MusicEngine::render(float* music, float* ambience, int frames) { if (m_music) DmPerformance_renderPcm(m_music, music, n, static_cast(DmRender_FLOAT | DmRender_STEREO)); renderMotif(music, frames); + // The motif is a secondary segment on the soundtrack's performance in + // MM2, so it shares the port and its reverb. + m_musicReverb.process(music, frames); + if (!m_stereo) + foldToMono(music, frames); } if (ambience) { std::fill(ambience, ambience + n, 0.0f); if (m_ambience && !m_ambienceSegment.empty()) DmPerformance_renderPcm(m_ambience, ambience, n, static_cast(DmRender_FLOAT | DmRender_STEREO)); + // The ambience segment has its own MMDMusicManager in MM2 + // (mmGameMusicData::LoadAmbientSFX), whose port has the reverb too. + // It runs on after a stop so the tail rings out. + m_ambienceReverb.process(ambience, frames); + if (!m_stereo) + foldToMono(ambience, frames); } } @@ -740,7 +769,11 @@ std::vector MusicPlayer::songSegments(int song, bool cruise) const } void MusicPlayer::playMenu() { - loadThen({m_tables.menu}, [](MusicEngine& e) { e.setState(MusicState::Menu); }); + const bool stereo = m_stereo.load(std::memory_order_relaxed); + loadThen({m_tables.menu}, [stereo](MusicEngine& e) { + e.setStereo(stereo); + e.setState(MusicState::Menu); + }); } void MusicPlayer::startRace(int song, bool cruise, bool play) { @@ -750,7 +783,9 @@ void MusicPlayer::startRace(int song, bool cruise, bool play) { const auto& songs = cruise ? m_tables.cruise : m_tables.race; if (song < 0 && !songs.empty()) song = MusicTables::pickSong(static_cast(songs.size())); - loadThen(songSegments(song, cruise), [song, cruise, play](MusicEngine& e) { + const bool stereo = m_stereo.load(std::memory_order_relaxed); + loadThen(songSegments(song, cruise), [song, cruise, play, stereo](MusicEngine& e) { + e.setStereo(stereo); e.selectSong(song, cruise); if (play) e.setState(MusicState::Racing); @@ -776,7 +811,11 @@ void MusicPlayer::setAmbience(std::string_view which) { } void MusicPlayer::playSegment(std::string_view name) { - loadThen({std::string(name)}, [n = std::string(name)](MusicEngine& e) { e.playSegment(n); }); + const bool stereo = m_stereo.load(std::memory_order_relaxed); + loadThen({std::string(name)}, [n = std::string(name), stereo](MusicEngine& e) { + e.setStereo(stereo); + e.playSegment(n); + }); } void MusicPlayer::stop() { diff --git a/src/audio/Music.h b/src/audio/Music.h index 377b293..5540467 100644 --- a/src/audio/Music.h +++ b/src/audio/Music.h @@ -4,6 +4,7 @@ // played with GothicKit/dmusic. See docs/music.md. #include "audio/Mixer.h" +#include "audio/SynthReverb.h" #include "vfs/Vfs.h" #include @@ -136,8 +137,10 @@ enum class MusicTiming { Auto, Immediate, Beat, Measure }; // Single-threaded music core: one performance for the soundtrack, one for the // "Big Air" motif (DirectMusic played it as a secondary segment; dmusic has no // secondary segments, so it is layered on its own performance) and one for city -// ambience. Not thread-safe; MusicPlayer drives it from a worker thread, tools -// and tests drive it directly. +// ambience. Each output goes through the Microsoft Synthesizer's reverb +// (SynthReverb), as MM2's synth ports have it on, and is mono when MM2 would +// have opened a mono port. Not thread-safe; MusicPlayer drives it from a +// worker thread, tools and tests drive it directly. class MusicEngine { public: MusicEngine(std::shared_ptr library, MusicTables tables, int sampleRate); @@ -169,6 +172,17 @@ class MusicEngine { // state changes never stall rendering. void preload(); + // DMusicObject::InitPort opens the synth port with + // AudManagerBase::IsStereo() + 1 audio channels: with STEREO FX set to + // mono the music and the ambience are mono (both channels carry + // (left + right) / 2; inferred: how the synth folds its pans into one + // channel is not known). + void setStereo(bool stereo) { m_stereo = stereo; } + bool stereo() const { return m_stereo; } + // The synth's reverb (DMUS_EFFECT_REVERB, on in MM2); off for comparisons. + void setReverb(bool on); + bool reverb() const { return m_musicReverb.enabled(); } + // Renders `frames` stereo frames of music and of ambience (either pointer may be null). void render(float* music, float* ambience, int frames); @@ -198,6 +212,9 @@ class MusicEngine { int m_motifRepeats = 0; // plays left after the current one std::string m_ambienceSegment; std::vector m_scratch; + SynthReverb m_musicReverb; + SynthReverb m_ambienceReverb; + bool m_stereo = true; }; // Threaded front end: a worker thread renders the engine ahead into two @@ -224,6 +241,14 @@ class MusicPlayer { std::shared_ptr musicStream() { return m_musicStream; } std::shared_ptr ambienceStream() { return m_ambienceStream; } + // AudManagerBase::IsStereo: STEREO FX is not mono. MM2 reads it when it + // opens its synth port (DMusicObject::InitPort), which it does when a + // phase starts its music: the menu music (mmInterface::PlayUIMusic) and + // a race's music or ambience (mmGameMusicData's loaders), each phase + // with a new MMDMusicManager (EndPhase deletes the old one). So a change + // is heard from the next playMenu() or startRace(). + void setStereo(bool stereo) { m_stereo.store(stereo, std::memory_order_relaxed); } + void playMenu(); // song < 0: random. Preloads the song's segments in the background, then // plays Start, or with play = false only selects the song (a @@ -256,6 +281,7 @@ class MusicPlayer { std::condition_variable m_wake; std::deque> m_commands; std::atomic m_quit{false}; + std::atomic m_stereo{true}; std::atomic m_underruns{0}; struct LoadRequest { std::vector names; diff --git a/src/audio/SynthReverb.cpp b/src/audio/SynthReverb.cpp new file mode 100644 index 0000000..5fd21ea --- /dev/null +++ b/src/audio/SynthReverb.cpp @@ -0,0 +1,182 @@ +#include "audio/SynthReverb.h" + +#include +#include +#include + +namespace mm2::audio { +namespace { + +// The allpass lattices' coefficient, the golden ratio's inverse. +constexpr float kPhi = 0.61803401f; +// The first comb's delay, the step between the delays (2^0.25) and the share +// of a pair's delays that goes to its allpass. +constexpr float kBaseDelay = 0.045f; +constexpr float kDelayStep = 1.18920707f; +constexpr float kAllpassShare = 0.11546667f; + +// Loop values this small are set to zero, so a decaying tail ends in exact +// silence instead of denormal numbers (which are slow on x86). +constexpr float kTiny = 1e-20f; +float flush(float v) { return std::abs(v) < kTiny ? 0.0f : v; } + +// Input the loops accept; anything else (NaN, infinity) is fed as silence so +// one bad sample cannot poison the reverb for good. +constexpr float kMaxInput = 1.0e4f; +float sane(float v) { return std::abs(v) <= kMaxInput ? v : 0.0f; } + +std::size_t roundDelay(float v) { return static_cast(std::lround(v)); } + +} // namespace + +SynthReverb::SynthReverb(int sampleRate, const SynthReverbParams& params) : m_rate(std::max(sampleRate, 1)) { + m_factor = std::max(1, static_cast(std::lround(static_cast(m_rate) / kPortRate))); + computeDelays(); + setParams(params); +} + +void SynthReverb::computeDelays() { + const float rate = static_cast(internalRate()); + const std::size_t d0 = std::max(roundDelay(rate * kBaseDelay), 8); + const std::size_t d1 = roundDelay(static_cast(d0) * kDelayStep); + const std::size_t d2 = roundDelay(static_cast(d1) * kDelayStep); + const std::size_t d3 = roundDelay(static_cast(d2) * kDelayStep); + const std::size_t a0 = roundDelay(static_cast(d0 + d2) * kAllpassShare); + const std::size_t a1 = roundDelay(static_cast(d1 + d3) * kAllpassShare); + // Pair 0 (combs 0 and 1) shares allpass 0, pair 1 (combs 2 and 3) + // allpass 1; each loop's total delay is its comb's plus its allpass's. + m_combDelay = {d0 - a0, d2 - a0, d1 - a1, d3 - a1}; + m_allpassDelay = {a0, a1}; + const std::size_t combSize = std::bit_ceil(d3 + 1); + const std::size_t allpassSize = std::bit_ceil(a1 + 1); + m_comb.assign(combSize, {}); + m_allpass1.assign(allpassSize, {}); + m_allpass2.assign(allpassSize, {}); + m_combMask = combSize - 1; + m_allpassMask = allpassSize - 1; + reset(); +} + +void SynthReverb::setParams(const SynthReverbParams& p) { + m_params.inGain = std::clamp(p.inGain, -96.0f, 0.0f); + m_params.reverbMix = std::clamp(p.reverbMix, -96.0f, 0.0f); + m_params.reverbTime = std::clamp(p.reverbTime, 0.001f, 3000.0f); + m_params.highFreqRTRatio = std::clamp(p.highFreqRTRatio, 0.001f, 0.999f); + computeCoefficients(); +} + +void SynthReverb::computeCoefficients() { + // Decay per sample, as a power of ten: -3 (60 dB) over the reverb time. + const double perSample = -3000.0 / (static_cast(internalRate()) * m_params.reverbTime); + // The extra decay of the highest frequencies, so that theirs takes + // reverbTime * ratio. + const double perSampleHf = perSample * (1.0 / m_params.highFreqRTRatio - 1.0); + for (std::size_t i = 0; i < 2; ++i) { + const double delay = static_cast(m_allpassDelay[i]); + m_allpassGain[i] = static_cast(std::pow(10.0, delay * perSample)); + } + double sum = 0.0; + for (std::size_t c = 0; c < 4; ++c) { + const std::size_t pair = c / 2; + const double gain = std::pow(10.0, static_cast(m_combDelay[c]) * perSample); + // The loop filter averages this sample and the previous one with + // weight `blend`, so the highest frequency is scaled by 1 - 2 * blend + // per pass on top of `gain`. + const double loop = static_cast(m_combDelay[c] + m_allpassDelay[pair]); + const double blend = (1.0 - std::pow(10.0, loop * perSampleHf)) * 0.5; + m_loop[c] = {static_cast(gain * (1.0 - blend)), static_cast(gain * blend)}; + // The loop's gain at 0 Hz, summed over its first passes: the reverb + // input is scaled so the four loops' energy comes out at a fixed level. + const double g = gain * static_cast(m_allpassGain[pair]); + const double passes = g * (((g + 1.0) * g + 1.0) * g + 1.0) + 1.0; + sum += passes * passes; + } + // fReverbMix is the reverb's share of the power: the direct sound keeps + // the rest. + const double inGain = std::pow(10.0, static_cast(m_params.inGain) / 20.0); + const double mix = std::pow(10.0, static_cast(m_params.reverbMix) / 10.0); + m_dry = static_cast(std::sqrt(1.0 - mix) * inGain); + m_wet = static_cast(std::sqrt(mix) * (4.0 / std::sqrt(sum)) * inGain); +} + +void SynthReverb::reset() { + std::ranges::fill(m_comb, std::array{}); + std::ranges::fill(m_allpass1, std::array{}); + std::ranges::fill(m_allpass2, std::array{}); + m_combPos = m_allpassPos = 0; + m_tap = m_tapPrev = {}; + m_out = m_outPrev = m_acc = {}; + m_phase = 0; +} + +void SynthReverb::tick() { + const std::size_t cp = m_combPos; + const float x0 = m_comb[(cp - m_combDelay[0]) & m_combMask][0]; + const float x1 = m_comb[(cp - m_combDelay[1]) & m_combMask][1]; + const float x2 = m_comb[(cp - m_combDelay[2]) & m_combMask][2]; + const float x3 = m_comb[(cp - m_combDelay[3]) & m_combMask][3]; + const std::size_t ap = m_allpassPos & m_allpassMask; + + // Pair 0: combs 0 and 1 through allpass 1. + const auto& a = m_allpass1[(m_allpassPos - m_allpassDelay[0]) & m_allpassMask]; + const float r1 = x1 * kPhi + a[0] * m_allpassGain[0]; + const float r2 = a[1] * m_allpassGain[0] - x0 * kPhi; + m_allpass1[ap] = {flush(r2 * kPhi + x0), flush(x1 - r1 * kPhi)}; + m_tap[0] = r1; + m_tap[1] = r2; + + // Pair 1: combs 2 and 3 through allpass 2. + const auto& b = m_allpass2[(m_allpassPos - m_allpassDelay[1]) & m_allpassMask]; + const float r3 = x3 * kPhi + b[0] * m_allpassGain[1]; + const float r4 = b[1] * m_allpassGain[1] - x2 * kPhi; + m_allpass2[ap] = {flush(r4 * kPhi + x2), flush(x3 - r3 * kPhi)}; + m_tap[2] = r3; + m_tap[3] = r4; + + m_outPrev = m_out; + m_out = {m_tap[0] + m_tap[2], m_tap[1] + m_tap[3]}; +} + +void SynthReverb::feed(float left, float right) { + const float wl = sane(left) * m_wet; + const float wr = sane(right) * m_wet; + auto& slot = m_comb[m_combPos & m_combMask]; + // The third comb takes the right input inverted, which decorrelates the + // pairs' outputs. + const float in[4] = {wl, wr, -wr, wl}; + for (std::size_t c = 0; c < 4; ++c) + slot[c] = flush(m_tap[c] * m_loop[c][0] + m_tapPrev[c] * m_loop[c][1] + in[c]); + m_tapPrev = m_tap; + ++m_combPos; + ++m_allpassPos; +} + +void SynthReverb::process(float* stereo, int frames) { + if (!m_enabled || !stereo || frames <= 0) + return; + const float step = 1.0f / static_cast(m_factor); + for (int i = 0; i < frames; ++i) { + if (m_phase == 0) + tick(); + ++m_phase; + // The reverb output between internal samples is interpolated + // linearly, so it reaches this internal sample's value on its last frame. + const float t = static_cast(m_phase) * step; + const float wetL = m_outPrev[0] + (m_out[0] - m_outPrev[0]) * t; + const float wetR = m_outPrev[1] + (m_out[1] - m_outPrev[1]) * t; + float& l = stereo[static_cast(i) * 2]; + float& r = stereo[static_cast(i) * 2 + 1]; + m_acc[0] += l; + m_acc[1] += r; + l = l * m_dry + wetL; + r = r * m_dry + wetR; + // The input is averaged over the internal sample's frames. + if (m_phase == m_factor) { + feed(m_acc[0] * step, m_acc[1] * step); + m_acc = {}; + m_phase = 0; + } + } +} + +} // namespace mm2::audio diff --git a/src/audio/SynthReverb.h b/src/audio/SynthReverb.h new file mode 100644 index 0000000..ede6129 --- /dev/null +++ b/src/audio/SynthReverb.h @@ -0,0 +1,114 @@ +#pragma once + +// The reverb of the Microsoft Synthesizer, the DirectMusic software synth MM2 +// plays its music through. DMusicObject::InitPort opens that port with +// DMUS_EFFECT_REVERB (the object's reverb flag is set by its constructor and +// the synth's caps report the effect), and MM2 never sets the reverb's +// parameters (no IKsControl call on the port), so the synth's defaults apply: +// DMUS_WAVES_REVERB_PARAMS as the DirectX 7 documentation gives them +// (SynthReverbParams). Every DirectMusic sound MM2 makes goes through such a +// port: the soundtrack with its Big Air motif (a secondary segment on the same +// performance) and the city ambience segment (its own MMDMusicManager, made by +// mmGameMusicData::LoadAmbientSFX, Init'ed the same way). +// +// The reverb is Waves' "SimpleVerb" (TrueVerb licensed to Microsoft), which is +// proprietary. This is an approximation (inferred): its structure follows +// OpenMPT's emulation of the DirectX 8 Waves Reverb DMO (soundlib/plugins/dmo/ +// WavesReverb.cpp, BSD-3-Clause, by the OpenMPT developers), which takes the +// same four parameters: four comb delays of 45 ms * 2^(n/4) cross-coupled in +// pairs by allpass lattices (golden-ratio coefficients), a two-tap low-pass in +// each loop for the high-frequency decay, and a dry/wet split that keeps the +// output power constant. That the DirectX 7 synth's reverb is the same +// algorithm as the DirectX 8 DMO is itself inferred (same vendor, parameters +// and documentation). See docs/music.md. + +#include +#include +#include + +namespace mm2::audio { + +// DMUS_WAVES_REVERB_PARAMS (dmusicc.h), with the Microsoft Synthesizer's +// documented defaults. +struct SynthReverbParams { + // fInGain: input gain in dB, applied to the direct sound and the reverb. + float inGain = 0.0f; + // fReverbMix in dB: 0 is all reverb and no direct sound, lower is drier; + // the coefficients keep the overall level about the same. + float reverbMix = -10.0f; + // fReverbTime: decay time to -60 dB, in milliseconds. + float reverbTime = 1000.0f; + // fHighFreqRTRatio: the high frequencies' decay time over fReverbTime. + float highFreqRTRatio = 0.001f; +}; + +// Stereo reverb applied in place to interleaved stereo float frames. Not +// thread-safe; MusicEngine runs one per DirectMusic stream on its thread. +class SynthReverb { +public: + // MM2's synth port runs at AudManager::GetSamplesPerSecond, which is + // always 22050 Hz (the constructor's value; SetBitDepthAndSampleRate has + // no caller). The reverb's loop filter acts at its own rate's Nyquist + // frequency, so it runs at the output rate divided by the whole number + // that comes closest to this (24 kHz at 48 kHz, 22050 Hz at 44.1 kHz). + static constexpr int kPortRate = 22050; + + explicit SynthReverb(int sampleRate, const SynthReverbParams& params = {}); + + // Values outside the DirectSound Waves reverb's ranges are clamped: + // gains -96..0 dB, time 0.001..3000 ms, ratio 0.001..0.999. + void setParams(const SynthReverbParams& params); + const SynthReverbParams& params() const { return m_params; } + + // Off is a port without DMUS_EFFECT_REVERB: the input passes unchanged. + void setEnabled(bool enabled) { m_enabled = enabled; } + bool enabled() const { return m_enabled; } + + // Processes `frames` interleaved stereo frames in place. + void process(float* stereo, int frames); + // Forgets the reverb's tail. + void reset(); + + int sampleRate() const { return m_rate; } + int internalRate() const { return m_rate / m_factor; } + float dryGain() const { return m_dry; } + float wetGain() const { return m_wet; } + +private: + void computeDelays(); + void computeCoefficients(); + // One sample at the internal rate: the delay lines' outputs through the + // allpass lattices (the reverb's output, which does not depend on the + // current input), then feed() writes the loops with the input. + void tick(); + void feed(float left, float right); + + int m_rate; + int m_factor = 1; // output frames per internal sample + SynthReverbParams m_params; + bool m_enabled = true; + + // Delays in internal samples: the four combs, then the two allpasses. + std::array m_combDelay{}; + std::array m_allpassDelay{}; + // Coefficients: each comb's loop filter (current, previous sample), each + // allpass's decay, and the direct and reverb input gains. + std::array, 4> m_loop{}; + std::array m_allpassGain{}; + float m_dry = 1.0f; + float m_wet = 0.0f; + + // State. + std::vector> m_comb; + std::vector> m_allpass1, m_allpass2; + std::size_t m_combMask = 0, m_allpassMask = 0; + std::size_t m_combPos = 0, m_allpassPos = 0; + std::array m_tap{}; // this sample's loop outputs + std::array m_tapPrev{}; // the previous sample's + std::array m_out{}; // the reverb output of this internal sample + std::array m_outPrev{}; // and of the previous one (for the interpolation) + std::array m_acc{}; // input summed over the current internal sample + int m_phase = 0; // output frames into the current internal sample +}; + +} // namespace mm2::audio diff --git a/tests/audio/test_synth_reverb.cpp b/tests/audio/test_synth_reverb.cpp new file mode 100644 index 0000000..c10908b --- /dev/null +++ b/tests/audio/test_synth_reverb.cpp @@ -0,0 +1,325 @@ +// The Microsoft Synthesizer's reverb on the music (DMusicObject::InitPort's +// DMUS_EFFECT_REVERB, DMUS_WAVES_REVERB_PARAMS defaults) and the mono music +// of STEREO FX mono (InitPort's AudManagerBase::IsStereo() + 1 channels). +#include "TestData.h" +#include "audio/Music.h" +#include "audio/SynthReverb.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace mm2; +using namespace mm2::audio; + +namespace { + +// The reverb's response to a unit impulse in the left channel, without the +// direct sound. +std::vector impulseResponse(int rate, double seconds, const SynthReverbParams& params = {}) { + SynthReverb reverb(rate, params); + std::vector buf(static_cast(seconds * rate) * 2, 0.0f); + buf[0] = 1.0f; + reverb.process(buf.data(), static_cast(buf.size() / 2)); + buf[0] -= reverb.dryGain(); + return buf; +} + +// Four RBJ band-passes (0 dB peak) in a row over one channel of interleaved +// stereo: steep enough that a fast-decaying band is not swamped by its +// neighbours' slower tails. +std::vector bandPass(const std::vector& stereo, int channel, int rate, double freq, double q) { + const double w = 2.0 * std::numbers::pi * freq / rate; + const double alpha = std::sin(w) / (2.0 * q); + const double a0 = 1.0 + alpha; + const double b0 = alpha / a0, b2 = -alpha / a0, a1 = -2.0 * std::cos(w) / a0, a2 = (1.0 - alpha) / a0; + std::vector out(stereo.size() / 2); + for (std::size_t i = 0; i < out.size(); ++i) + out[i] = stereo[i * 2 + static_cast(channel)]; + for (int stage = 0; stage < 4; ++stage) { + double x1 = 0, x2 = 0, y1 = 0, y2 = 0; + for (double& v : out) { + const double x = v; + const double y = b0 * x + b2 * x2 - a1 * y1 - a2 * y2; + x2 = x1; + x1 = x; + y2 = y1; + y1 = y; + v = y; + } + } + return out; +} + +// Decay time to -60 dB in a band, from the Schroeder integral of both +// channels' energy, fitted between -5 and -25 dB (T20). +double decayTime(const std::vector& ir, int rate, double freq) { + const auto l = bandPass(ir, 0, rate, freq, 1.4); + const auto r = bandPass(ir, 1, rate, freq, 1.4); + std::vector edc(l.size()); + double acc = 0; + for (std::size_t i = l.size(); i-- > 0;) { + acc += l[i] * l[i] + r[i] * r[i]; + edc[i] = acc; + } + const double total = edc[0]; + double sx = 0, sy = 0, sxx = 0, sxy = 0; + int n = 0; + for (std::size_t i = 0; i < edc.size(); ++i) { + const double db = 10.0 * std::log10(edc[i] / total); + if (db > -5.0) + continue; + if (db < -25.0) + break; + const double t = static_cast(i) / rate; + sx += t; + sy += db; + sxx += t * t; + sxy += t * db; + ++n; + } + const double slope = (n * sxy - sx * sy) / (n * sxx - sx * sx); // dB per second + return -60.0 / slope; +} + +std::vector noise(std::size_t frames, float amplitude, unsigned seed) { + std::mt19937 rng(seed); + std::uniform_real_distribution dist(-amplitude, amplitude); + std::vector v(frames * 2); + for (float& f : v) + f = dist(rng); + return v; +} + +double correlation(const std::vector& stereo) { + double ll = 0, rr = 0, lr = 0; + for (std::size_t i = 0; i + 1 < stereo.size(); i += 2) { + ll += static_cast(stereo[i]) * stereo[i]; + rr += static_cast(stereo[i + 1]) * stereo[i + 1]; + lr += static_cast(stereo[i]) * stereo[i + 1]; + } + return lr / std::sqrt(ll * rr); +} + +} // namespace + +TEST(SynthReverb, DefaultsAreTheDirectX7SynthsWavesReverbParams) { + const SynthReverbParams p; + EXPECT_EQ(p.inGain, 0.0f); + EXPECT_EQ(p.reverbMix, -10.0f); + EXPECT_EQ(p.reverbTime, 1000.0f); + EXPECT_EQ(p.highFreqRTRatio, 0.001f); + // -10 dB of reverb mix leaves 90% of the power to the direct sound. + SynthReverb reverb(48000); + EXPECT_NEAR(reverb.dryGain(), std::sqrt(0.9), 1e-6); + EXPECT_GT(reverb.wetGain(), 0.0f); + EXPECT_LT(reverb.wetGain(), std::sqrt(0.1f)); +} + +TEST(SynthReverb, RunsNearTheSynthPortsRate) { + EXPECT_EQ(SynthReverb(48000).internalRate(), 24000); + EXPECT_EQ(SynthReverb(44100).internalRate(), 22050); + EXPECT_EQ(SynthReverb(22050).internalRate(), 22050); + EXPECT_EQ(SynthReverb(96000).internalRate(), 24000); + EXPECT_EQ(SynthReverb(16000).internalRate(), 16000); +} + +TEST(SynthReverb, LowFrequenciesDecayInTheReverbTime) { + for (int rate : {48000, 44100, 22050}) { + const auto ir = impulseResponse(rate, 4.0); + EXPECT_NEAR(decayTime(ir, rate, 250.0), 1.0, 0.1) << rate; + } + SynthReverbParams p; + p.reverbTime = 2000.0f; + EXPECT_NEAR(decayTime(impulseResponse(48000, 6.0, p), 48000, 250.0), 2.0, 0.2); + p.reverbTime = 500.0f; + EXPECT_NEAR(decayTime(impulseResponse(48000, 3.0, p), 48000, 250.0), 0.5, 0.05); +} + +TEST(SynthReverb, HighFrequenciesDecayFaster) { + const auto ir = impulseResponse(48000, 4.0); + const double low = decayTime(ir, 48000, 250.0); + const double mid = decayTime(ir, 48000, 4000.0); + const double high = decayTime(ir, 48000, 8000.0); + EXPECT_LT(mid, 0.85 * low); + EXPECT_LT(high, 0.5 * low); + EXPECT_LT(high, mid); + // A ratio near 1 keeps the highs about as long as the lows. + SynthReverbParams p; + p.highFreqRTRatio = 0.999f; + const auto bright = impulseResponse(48000, 4.0, p); + EXPECT_NEAR(decayTime(bright, 48000, 4000.0), decayTime(bright, 48000, 250.0), 0.1); +} + +TEST(SynthReverb, TheReverbIsStereoAndDecorrelated) { + // A mono input comes out of the two channels differently. + SynthReverb reverb(48000); + auto buf = noise(48000, 0.5f, 1); + for (std::size_t i = 0; i < buf.size(); i += 2) + buf[i + 1] = buf[i]; + reverb.process(buf.data(), 48000); + for (std::size_t i = 0; i < buf.size(); i += 2) + buf[i] -= buf[i + 1]; // left - right: the reverb's difference only + double side = 0; + for (std::size_t i = 0; i < buf.size(); i += 2) + side += static_cast(buf[i]) * buf[i]; + EXPECT_GT(side, 1.0); +} + +TEST(SynthReverb, OffOrWithoutMixTheDirectSoundIsUnchanged) { + const auto input = noise(20000, 0.5f, 2); + auto off = input; + SynthReverb reverb(48000); + reverb.setEnabled(false); + reverb.process(off.data(), 20000); + EXPECT_EQ(off, input); // a port without DMUS_EFFECT_REVERB + + SynthReverbParams p; + p.reverbMix = -96.0f; // the driest mix + SynthReverb dry(48000, p); + auto out = input; + dry.process(out.data(), 20000); + for (std::size_t i = 0; i < out.size(); ++i) + ASSERT_NEAR(out[i], input[i], 1e-4f) << i; +} + +TEST(SynthReverb, InGainScalesBothPaths) { + SynthReverbParams p; + p.inGain = -6.0f; + SynthReverb quiet(48000, p); + SynthReverb normal(48000); + const float g = std::pow(10.0f, -6.0f / 20.0f); + EXPECT_NEAR(quiet.dryGain(), normal.dryGain() * g, 1e-6f); + EXPECT_NEAR(quiet.wetGain(), normal.wetGain() * g, 1e-6f); +} + +TEST(SynthReverb, BlockSizeDoesNotMatter) { + const auto input = noise(9000, 0.5f, 3); + auto whole = input; + SynthReverb a(48000); + a.process(whole.data(), 9000); + auto pieces = input; + SynthReverb b(48000); + int done = 0; + for (int n : {1, 7, 512, 3, 1000, 2477}) { + b.process(pieces.data() + static_cast(done) * 2, n); + done += n; + } + b.process(pieces.data() + static_cast(done) * 2, 9000 - done); + EXPECT_EQ(whole, pieces); +} + +TEST(SynthReverb, SilenceStaysSilentAndTheTailEndsInZeros) { + SynthReverb reverb(48000); + std::vector zeros(48000 * 2, 0.0f); + reverb.process(zeros.data(), 48000); + for (float f : zeros) + ASSERT_EQ(f, 0.0f); + + auto burst = noise(4800, 1.0f, 4); + reverb.process(burst.data(), 4800); + // 60 dB a second: well under the flush level after 10 s. + std::vector tail(48000 * 2); + for (int s = 0; s < 10; ++s) { + std::ranges::fill(tail, 0.0f); + reverb.process(tail.data(), 48000); + } + std::ranges::fill(tail, 0.0f); + reverb.process(tail.data(), 48000); + for (float f : tail) { + ASSERT_EQ(f, 0.0f); + ASSERT_FALSE(std::fpclassify(f) == FP_SUBNORMAL); + } +} + +TEST(SynthReverb, NoNaNFromBadInputOrExtremeParameters) { + SynthReverb reverb(48000); + auto buf = noise(48000, 0.5f, 5); + buf[1000] = std::numeric_limits::quiet_NaN(); + buf[2001] = std::numeric_limits::infinity(); + buf[3000] = -std::numeric_limits::infinity(); + reverb.process(buf.data(), 48000); + for (std::size_t i = 0; i < buf.size(); ++i) { + if (i != 1000 && i != 2001 && i != 3000) { // the direct sound passes them as they are + ASSERT_TRUE(std::isfinite(buf[i])) << i; + } + } + auto after = noise(48000, 0.5f, 6); + reverb.process(after.data(), 48000); + for (float f : after) + ASSERT_TRUE(std::isfinite(f)); + + for (const SynthReverbParams& p : {SynthReverbParams{0.0f, 0.0f, 3000.0f, 0.999f}, + SynthReverbParams{0.0f, 0.0f, 0.001f, 0.001f}, + SynthReverbParams{-96.0f, -96.0f, 1.0f, 0.5f}, + SynthReverbParams{20.0f, 10.0f, 1e9f, 5.0f}}) { + SynthReverb r(48000, p); + auto loud = noise(96000, 1.0f, 7); + r.process(loud.data(), 96000); + float peak = 0; + for (float f : loud) { + ASSERT_TRUE(std::isfinite(f)); + peak = std::max(peak, std::abs(f)); + } + EXPECT_LT(peak, 10.0f); // bounded: the loops lose energy every pass + } +} + +// With retail data: the music through MusicEngine, with and without the +// reverb, and mono. +TEST(SynthReverb, RaceMusicWidensWithTheReverbAndIsMonoWhenAsked) { + MM2_REQUIRE_GAME_DATA(); + auto lib = std::make_shared(*test::gameData()); + MusicEngine engine(lib, MusicTables::load(*test::gameData()), 48000); + EXPECT_TRUE(engine.reverb()); // on, as MM2's port has it + engine.setReverb(false); + engine.selectSong(0, false); + engine.preload(); + engine.setState(MusicState::Racing); + constexpr int kFrames = 48000 * 4; + std::vector dry(static_cast(kFrames) * 2); + for (int f = 0; f < kFrames; f += 512) + engine.render(dry.data() + static_cast(f) * 2, nullptr, std::min(512, kFrames - f)); + // The same notes through the reverb, as the engine applies it. + auto wet = dry; + SynthReverb(48000).process(wet.data(), kFrames); + EXPECT_LT(correlation(wet), correlation(dry)); + + engine.setReverb(true); + engine.setStereo(false); + std::vector mono(48000 * 2); + engine.render(mono.data(), nullptr, 48000); + double energy = 0; + for (std::size_t i = 0; i < mono.size(); i += 2) { + ASSERT_EQ(mono[i], mono[i + 1]); + energy += static_cast(mono[i]) * mono[i]; + } + EXPECT_GT(energy, 1.0); +} + +TEST(SynthReverb, PlayerTakesTheStereoSettingWhenTheMusicStarts) { + MM2_REQUIRE_GAME_DATA(); + MusicPlayer player(*test::gameData(), 48000); + ASSERT_TRUE(player.ok()); + player.setStereo(false); + player.playMenu(); + auto stream = player.musicStream(); + std::vector block(2 * 480); + bool heard = false; + for (int i = 0; i < 300 && !heard; ++i) { // up to 3 s for the loader + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + stream->render(block.data(), 480); + for (std::size_t j = 0; j < block.size(); j += 2) { + ASSERT_EQ(block[j], block[j + 1]); + heard = heard || block[j] != 0.0f; + } + } + EXPECT_TRUE(heard); +} diff --git a/tools/mm2tool/cmd_music.cpp b/tools/mm2tool/cmd_music.cpp index c2223fe..1f99cca 100644 --- a/tools/mm2tool/cmd_music.cpp +++ b/tools/mm2tool/cmd_music.cpp @@ -87,6 +87,20 @@ void printStats(const std::vector& stereo, int rate) { auto db = [](double v) { return v > 1e-9 ? 20.0 * std::log10(v) : -180.0; }; std::println(" {:3}s rms {:7.1f} dBFS peak {:7.1f} dBFS", sec, db(rms), db(peak)); } + // Stereo image over the whole render: the correlation of the channels + // (1 is mono) and the side (L - R) to mid (L + R) energy ratio. + double ll = 0, rr = 0, lr = 0, side = 0, mid = 0; + for (std::size_t i = 0; i + 1 < stereo.size(); i += 2) { + const double l = stereo[i], r = stereo[i + 1]; + ll += l * l; + rr += r * r; + lr += l * r; + side += (l - r) * (l - r); + mid += (l + r) * (l + r); + } + const double corr = ll > 0 && rr > 0 ? lr / std::sqrt(ll * rr) : 1.0; + const double sideMid = side > 0 && mid > 0 ? 10.0 * std::log10(side / mid) : -180.0; + std::println(" stereo: correlation {:.3f} side/mid {:.1f} dB", corr, sideMid); } int cmdMusicInfo(std::span args) { @@ -140,7 +154,9 @@ int cmdMusicInfo(std::span args) { } // music [--states s:sec,...] [--ambience city] -// [--bigair sec] [--seconds N] [--wav out.wav] +// [--bigair sec] [--seconds N] [--mono] [--dry] [--wav out.wav] +// --mono plays as with STEREO FX set to mono; --dry turns the synth's reverb +// off (renders as OpenMM2 did before it had one). int cmdMusic(std::span args) { if (args.size() < 2) return 2; @@ -152,7 +168,7 @@ int cmdMusic(std::span args) { bool cruise = false; double seconds = 10.0; double bigAir = -1.0; - bool profile = false; + bool profile = false, mono = false, dry = false; std::string wav, ambience, statesSpec; for (std::size_t i = 1; i < args.size(); ++i) { const std::string_view a = args[i]; @@ -170,6 +186,10 @@ int cmdMusic(std::span args) { ambience = next(); else if (a == "--profile") profile = true; + else if (a == "--mono") + mono = true; + else if (a == "--dry") + dry = true; else if (a == "--bigair") bigAir = str::parseDouble(next()).value_or(-1.0); else if (!a.starts_with("--")) @@ -203,6 +223,8 @@ int cmdMusic(std::span args) { // Offline: drive the engine directly, one block at a time. auto library = std::make_shared(*v); audio::MusicEngine engine(library, audio::MusicTables::load(*v), kRate); + engine.setStereo(!mono); + engine.setReverb(!dry); if (!segment.empty() && !engine.playSegment(segment)) return 1; if (song != -2) { @@ -258,6 +280,9 @@ int cmdMusic(std::span args) { // Live: through the mixer and the default audio device. auto mixer = std::make_shared(kRate); audio::MusicPlayer player(*v, kRate); + player.setStereo(!mono); + if (dry) + std::println(stderr, "note: --dry only applies to --wav renders"); mixer->addStream(player.musicStream(), audio::Bus::Music); mixer->addStream(player.ambienceStream(), audio::Bus::Music); audio::AudioDevice device; @@ -300,7 +325,8 @@ int cmdMusic(std::span args) { const Registrar r1({"musicinfo", "", "load every soundtrack segment and print lengths", &cmdMusicInfo}); const Registrar r2({"music", " [--states state:sec,...] " - "[--ambience london|sf|underground] [--bigair sec] [--seconds N] [--wav out.wav [--profile]]", + "[--ambience london|sf|underground] [--bigair sec] [--seconds N] [--mono] " + "[--wav out.wav [--dry] [--profile]]", "render (--wav) or play the DirectMusic soundtrack", &cmdMusic}); } // namespace From aff8b463103214553b050abc1d9bf23ed3cda33c Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Tue, 13 Oct 2026 23:57:52 -0700 Subject: [PATCH 2/3] Ship OpenMPT's notice with the music reverb audio/SynthReverb follows the structure and constants of OpenMPT's Waves Reverb emulation, which is BSD-3-Clause: its copyright notice and conditions now ship with the packages as licenses/openmpt.txt, next to the fetched dependencies' licences. The reverb's files get their manifest rows (M, audio: MM2's synth reverb by an inferred algorithm). Co-Authored-By: Claude Opus 5.5 (1M context) --- cmake/Packaging.cmake | 5 +++++ docs/parity/README.md | 2 ++ packaging/licenses/openmpt.txt | 25 +++++++++++++++++++++++++ src/audio/SynthReverb.h | 3 ++- 4 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 packaging/licenses/openmpt.txt diff --git a/cmake/Packaging.cmake b/cmake/Packaging.cmake index 6b13561..0351f55 100644 --- a/cmake/Packaging.cmake +++ b/cmake/Packaging.cmake @@ -101,6 +101,11 @@ foreach(dep_dir IN LISTS _openmm2_dep_dirs) endif() endforeach() +# Code adapted from projects that are not fetched dependencies: their notices. +# audio/SynthReverb follows OpenMPT's Waves Reverb emulation (BSD-3-Clause). +install(FILES "${OPENMM2_PACKAGING_DIR}/licenses/openmpt.txt" DESTINATION "${OPENMM2_INSTALL_LICENSEDIR}" + COMPONENT app) + # --- Linux desktop integration --------------------------------------------- if(UNIX AND NOT APPLE) install(FILES "${OPENMM2_PACKAGING_DIR}/linux/${OPENMM2_APP_ID}.desktop" diff --git a/docs/parity/README.md b/docs/parity/README.md index 32d2f5f..cafb6c3 100644 --- a/docs/parity/README.md +++ b/docs/parity/README.md @@ -267,6 +267,8 @@ audited in [openmm2-only.md](openmm2-only.md). | `src/audio/MusicMotif.h` | 44 | M | audio | | `src/audio/SoundBank.cpp` | 70 | F | audio | | `src/audio/SoundBank.h` | 45 | F | audio | +| `src/audio/SynthReverb.cpp` | 182 | M | audio | +| `src/audio/SynthReverb.h` | 115 | M | audio | | `src/audio/TextFields.cpp` | 101 | F | audio | | `src/audio/TextFields.h` | 34 | F | audio | | `src/audio/Wav.cpp` | 100 | F | audio | diff --git a/packaging/licenses/openmpt.txt b/packaging/licenses/openmpt.txt new file mode 100644 index 0000000..6f8a33d --- /dev/null +++ b/packaging/licenses/openmpt.txt @@ -0,0 +1,25 @@ +Copyright (c) 2004-2026, OpenMPT Project Developers and Contributors +Copyright (c) 1997-2003, Olivier Lapicque +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of the OpenMPT project nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/src/audio/SynthReverb.h b/src/audio/SynthReverb.h index ede6129..24a4aa6 100644 --- a/src/audio/SynthReverb.h +++ b/src/audio/SynthReverb.h @@ -14,7 +14,8 @@ // The reverb is Waves' "SimpleVerb" (TrueVerb licensed to Microsoft), which is // proprietary. This is an approximation (inferred): its structure follows // OpenMPT's emulation of the DirectX 8 Waves Reverb DMO (soundlib/plugins/dmo/ -// WavesReverb.cpp, BSD-3-Clause, by the OpenMPT developers), which takes the +// WavesReverb.cpp, BSD-3-Clause, by the OpenMPT developers; its notice ships +// as licenses/openmpt.txt, packaging/licenses/openmpt.txt here), which takes the // same four parameters: four comb delays of 45 ms * 2^(n/4) cross-coupled in // pairs by allpass lattices (golden-ratio coefficients), a two-tap low-pass in // each loop for the high-frequency decay, and a dry/wet split that keeps the From f4e343058f0490f74a9f0a4a07805373bfe8b4b0 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Wed, 14 Oct 2026 00:24:01 -0700 Subject: [PATCH 3/3] Call this version 0.4.3 Co-Authored-By: Claude Opus 5.5 (1M context) --- CMakeLists.txt | 2 +- packaging/linux/io.github.lowlevelmetal.OpenMM2.metainfo.xml | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 90ed33e..3e81523 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.25...4.0) project(OpenMM2 - VERSION 0.4.2 + VERSION 0.4.3 DESCRIPTION "Open source reimplementation of Midtown Madness 2" HOMEPAGE_URL "https://github.com/lowlevelmetal/OpenMM2" LANGUAGES C CXX) diff --git a/packaging/linux/io.github.lowlevelmetal.OpenMM2.metainfo.xml b/packaging/linux/io.github.lowlevelmetal.OpenMM2.metainfo.xml index 06a32f8..2887471 100644 --- a/packaging/linux/io.github.lowlevelmetal.OpenMM2.metainfo.xml +++ b/packaging/linux/io.github.lowlevelmetal.OpenMM2.metainfo.xml @@ -44,6 +44,11 @@ 768 + + +

The music and city ambience play through the reverb the original game's DirectMusic synthesizer added, and in mono when the stereo option is set to mono.

+
+

The multiplayer net name is used as typed, without having to press Enter, and changing it no longer stalls the menus.