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/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/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/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/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/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/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.

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..24a4aa6 --- /dev/null +++ b/src/audio/SynthReverb.h @@ -0,0 +1,115 @@ +#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; 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 +// 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