Skip to content

Latest commit

 

History

History
229 lines (164 loc) · 20.3 KB

File metadata and controls

229 lines (164 loc) · 20.3 KB
name SharpModPlayer
description Cross-platform .NET MOD/tracker music player library with terminal, desktop GUI and WebAssembly front-ends

SharpModPlayer Agent Guide

A cross-platform C# port of Olivier Lapicque's Mod95 player, supporting MOD, S3M, XM, STM and 669 tracker formats. SharpMod/ is the engine library — no UI, no audio backend. Everything else is a front-end that pulls PCM out of it.

The library is a deliberately verbatim port and keeps the original C++ quirks (the unused instrument slot at index 0, both finetune and C5-speed stored per sample, very imperative mixing loops). Don't modernize engine code unless asked — behavioural fidelity is the point.

This file and CLAUDE.md cover the same ground and must be kept in sync. If you change one, change the other.

Project Structure

SharpMod/                            # Core library: format parsing & playback engine
  SharpMod.cs                        # Ctors + format sniffing + one ParseXxxFile() per format
  Helpers/
    Helpers.cs                       # CommandToString() (pattern-cell → display text)
    ExtensionMethods.cs              # LegacyEncoding.Cp437
    S3MTools.cs, XMTools.cs, STMTools.cs, C669Tools.cs   # Header structs + validation/effect maps
  Mod95/
    Mod95Internals.cs                # Read() (mixer) + ReadNote() (sequencer / effect dispatch)
    Mod95Data.cs                     # Types, Effects, ModInstrument, ModChannel, wavetables, PreAmpTable
    Mod95Interop.cs                  # Public read-only surface (Title, Row, Pattern, Channels, Position…)

SharpMod.ConsolePlayer/              # Terminal UI: PrettyConsole 6.x + OpenAL (OpenTK)
  Program.cs                         # Playlist expansion; spawns render loop, drives audio loop
  Cli.cs                             # CLI argument parsing
  OpenAlStreamPlayer.cs              # OpenAL context & buffer queue management
  WavExporter.cs                     # --export offline render
  Renderer/                          # ConsoleRenderer.cs (loop + input) + Channel, Samples,
                                     #   Info, SongProgress, Dialog components

SharpMod.PlayerGUI/                  # Desktop GUI: Eto.Forms + OpenAL
  SharpMod.PlayerGUI/                #   shared MainForm + Renderer
  SharpMod.PlayerGUI.{Wpf,Gtk,Mac}/  #   per-platform launchers

SharpMod.Wasm/                       # Browser player: [JSExport] bridge + Web Audio
  SharpModInterop.cs                 # The entire JS↔.NET surface
  wwwroot/main.js                    #   runtime bootstrap, audio pump, rAF loop
  wwwroot/mod-processor.js           #   AudioWorklet ring buffer
  wwwroot/view-patterns.js, view-samples.js
  wwwroot/track-picker.js            #   demo-track browser (GitHub listing + lazy probes)

SharpMod.ConsolePlayer.Tests/        # xUnit: ChannelRenderTests, XMLoaderTests,
                                     #   STMLoaderTests, EffectHandlerTests
Original Source Code/                # mod95src (Lapicque's C++) + openmpt-master, for reference
Release/                             # Shared OutputPath for every project; Release/mods/ is a
                                     #   checked-in corpus of real modules (see the .gitignore note
                                     #   under Gotchas -- the re-include is easy to break)

Build & Test

# Library + console player — the usual inner loop
dotnet build SharpMod.ConsolePlayer/SharpMod.ConsolePlayer.csproj

# Browser front-end
dotnet build   SharpMod.Wasm/SharpMod.Wasm.csproj
dotnet run     --project SharpMod.Wasm                        # dev server
dotnet publish SharpMod.Wasm/SharpMod.Wasm.csproj -c Release  # deployable: bin/Release/net10.0/publish/wwwroot/

# Tests
dotnet test SharpMod.ConsolePlayer.Tests
dotnet test SharpMod.ConsolePlayer.Tests --filter "FullyQualifiedName~XMLoaderTests"
dotnet test SharpMod.ConsolePlayer.Tests --filter "DisplayName~RestartPos_OutOfRange_ClampsToZero"

# Play something
dotnet run --project SharpMod.ConsolePlayer -- "Release/mods/CRONOLOG.S3M"

Target framework is net10.0 everywhere. All projects share OutputPath=..\Release\, so binaries land side by side in Release/.

Building the whole solution also builds the Eto.Forms macOS head, which emits a harmless "Can only create universal binary on macOS" warning on Windows. Prefer building the specific project you're working on.

Known-broken: the test project does not compile

SharpMod.ConsolePlayer.csproj:25 declares InternalsVisibleTo("SharpModConsolePlayer.Tests"), but the test assembly is named SharpMod.ConsolePlayer.Tests (with a dot). ChannelRenderTests.cs:6 therefore fails with CS0122: 'Channel' is inaccessible, and dotnet test cannot run at all. Fix that InternalsVisibleTo value before relying on the suite.

This is one symptom of a wider drift: project directories use dots (SharpMod.ConsolePlayer), C# namespaces do not (SharpModConsolePlayer, SharpModConsolePlayer.Renderer, SharpModConsolePlayer.Tests). .vscode/tasks.json and .vscode/launch.json still reference the pre-rename paths SharpModConsolePlayer/SharpModConsolePlayer.csproj, which no longer exist — the build, publish and watch tasks and the debug launch config are all broken.

Key Concepts

One engine, two internal pattern representations

This is the single most important thing to internalize. The sequencer understands only two cell layouts, and every loader transcodes into one of them:

  • MOD keeps its native 4-byte cell: [period-hi | inst-hi, period-lo, inst-lo | effect, param].
  • S3M, XM, STM and 669 are all normalized into the S3M-style 6-byte cell: [mode, note, instrument, volume, command, parameter]
    • mode0x20 = note present, 0x40 = volume present, 0x80 = command present; low 5 bits carry the channel index. mode == 0 means "empty cell, skip".
    • note(octave << 4) | semitone; 0xFE = note cut, 0xFF = note off.
    • instrument — sample index (0 = none).
    • command — effect letter as an index, 'A' → 1.
    • parameter — often nibble-encoded (e.g. 0x47 = up 4, down 7).

ReadNote() picks the stride with int inc = Type == Types.MOD ? 4 : 6; — the only format branch in the sequencer. CommandToString() in Helpers/Helpers.cs has the matching pair of branches for display.

Consequences before you touch a loader:

  • Patterns are always 64 rowsRow = (Row + 1) & 0x3F is hardcoded. Shorter patterns are padded; effects that would land past row 63 are dropped.
  • Transcoders live in SharpMod.cs: EncodeSTMCell, EncodeXMCell, inline 669 encoding, and InjectPatternEffect (synthesizes per-pattern speed/break commands that 669 stores in its header rather than in cells).
  • Format quirks are absorbed at transcode time by mapping onto S3M effect letters — see the effLetter table in ParseC669File for the 669→S3M mapping and why slides are re-emitted every row.

Format detection order is load-bearing

SoundFile's ctor sniffs magic bytes in a specific nested order:

  1. 0x438 — MOD tags (M.K., FLT4FLT9, 4CHN, 16CH, …), which also sets the channel count
  2. 0x2CSCRM → S3M
  3. offset 0"Extended Module: " → XM
  4. STM header validation
  5. XXXX → S3M fallback
  6. if / JN prefix → 669

Step 4 must precede step 5: ST2 stuffs its reserved field with 0x58 ('X'), which collides with the XXXX fallback. Falling through everything leaves ActiveSamples = 15 (legacy 15-instrument MOD).

Audio consumer contract

Every front-end runs the same loop: open the backend with the exact format passed to the SoundFile ctor, then repeatedly call sf.Read(buffer, length) and hand the bytes to the device, using the backend's queue depth as back-pressure rather than a fixed sleep. Read() returning 0 means end-of-song when Loop == false. Reference pseudocode is in README.md.

Front-end Backend Where the loop lives
ConsolePlayer OpenAL (OpenTK) OpenAlStreamPlayer.cs — keeps ~3 buffers queued; the AL context/source are reused across track switches to avoid handle exhaustion and keep the second track audible
Wasm Web Audio AudioWorklet main.js pump()/pumpChunk() — a 20 ms setInterval keeps TARGET_LEAD_SEC (0.25 s) of audio in the worklet's ring buffer
PlayerGUI OpenAL (OpenTK) Eto.Forms shared MainForm + per-platform heads

The engine is not thread-safe, by design. The console and WASM players both exploit this: the audio loop owns mutation while a separate render loop reads sf.Row, sf.Channels[], sf.Pos every frame with no locking. Torn reads are tolerated as visual noise. Keep it that way — don't add locks to the mixer's hot path.

WASM bridge

SharpModInterop.cs is the whole JS↔.NET surface: [JSExport] statics over a single static SoundFile? sf. Interop calls are expensive per-call, so per-frame data is packed into flat arrays with documented layouts instead of objects:

  • GetChannelStates() → 6 ints per channel: [muted, instrumentIndex, currentVolume, pan, isStereoSample, isActive]
  • GetInstrumentMeta()[length, volume, is16, isStereo, loopStart, loopEnd]
  • GetWaveformEnvelope(i, width) → interleaved [min, max] pairs
  • GetPatternData(i) → all 64 rows as one \n-joined string of fixed-width 14-char cells

When adding a per-frame field, extend an existing packed array and update its layout comment on both sides. Don't add a new round-trip.

ProbeMetadata(byte[]) is the one export that breaks both rules, deliberately: it parses a throwaway second SoundFile so the demo-track browser can describe a module without disturbing playback (safe — the engine holds no mutable static state), and it returns a U+001F-separated record instead of a packed array because it runs once per track, not per frame. Its field order is mirrored by PROBE_FIELDS in track-picker.js; change one and you must change the other.

JS side: main.js owns runtime bootstrap, the audio pump and the rAF loop; view-patterns.js / view-samples.js each expose init* / render* / reset*. loadedToken is bumped on every successful Load() so views know to drop caches. track-picker.js is the demo-track browser: a custom listbox (a <select> cannot render per-row stats) that lists Release/mods through the GitHub contents API and lazily probes each module as its row scrolls into view, capped at PROBE_CONCURRENCY and cached. Parsing the corpus's largest module blocks the main thread ~110 ms — inside the pump's 0.25 s lead, so probing during playback doesn't underrun.

Two things that are easy to break: the picker is a button + popup, not a form control, so the global shortcut handler in main.js must consult isTrackPickerBusy() (its HTMLInputElement/HTMLSelectElement early-out doesn't cover it); and Space is deliberately not handled by the picker button — only Enter and ArrowDown open it — because choosing a track leaves that button focused and Space still has to mean play/pause.

WASM publishing: the import map is load-bearing — never touch index.html

main.js starts with a plain import { dotnet } from './_framework/dotnet.js';, but a published build contains no _framework/dotnet.js — only fingerprinted files like dotnet.a2smcxlaab.js. That bare specifier resolves only because <OverrideHtmlAssetPlaceholders>true</OverrideHtmlAssetPlaceholders> makes the SDK rewrite two placeholders in index.html at publish time:

<link rel="preload" id="webassembly" />   <!-- becomes <link href="_framework/dotnet.<hash>.js" rel="preload" ...> -->
<script type="importmap"></script>        <!-- becomes the generated import map -->

The substituted copy is generated into obj/<cfg>/net10.0/staticwebassets/htmlassetplaceholders/publish/<hash>.html, then copied over index.html in the publish output.

  • Never post-process $(PublishDir)wwwroot/index.html. A target that rewrites it AfterTargets="Publish" leaves the destination newer than the SDK's generated source, so later publishes skip the copy and ship index.html with empty placeholders — no import map at all. The literal /_framework/dotnet.js then 404s, the server answers with its HTML error page, and Firefox reports NS_ERROR_CORRUPTED_CONTENT (HTML body rejected as a module script). This exact bug shipped once and was undone by the 2ff8040 rollback.
  • The breakage is timestamp-driven and therefore sticky: deleting the published index.html alone does not fix it. Clear obj/Release and bin/Release, then republish.
  • Verify before deploying — the published index.html must contain a populated import map:
    Select-String -Path SharpMod.Wasm/bin/Release/net10.0/publish/wwwroot/index.html -Pattern 'importmap','dotnet\.'
  • Don't work around a runtime-load failure by hand-rolling a loader that probes for dotnet.js candidates. The substitution removes the id="webassembly" attribute, so querySelector('link#webassembly') finds nothing in a published build. Fix the import map instead.
  • Local dotnet run cannot reproduce any of this: the dev server serves the unfingerprinted build, where _framework/dotnet.js genuinely exists.

Console renderer

Renderer/ConsoleRenderer.cs runs RenderLoop on its own task, polling a Func<SoundFile?> so track switches are picked up without restarting the loop. HandleInput owns all keyboard handling and the ViewMode (Patterns / Samples) switch. Components are statics that position the cursor themselves and cache aggressively — Samples.cs invalidates only on song / terminal-size / metadata-visibility / scroll-offset change, and delta-repaints just the columns whose cursor moved.

Rendering uses PrettyConsole 6.x. Invoke the pretty-console-expert skill before any styling, input, live-region or OutputPipe work — the v6 API differs substantially from earlier versions and from Spectre.Console. Keep durable UI (patterns, samples) on OutputPipe.Out and transient spinners on OutputPipe.Error.

Common Development Tasks

Adding a new tracker format

  1. Add SharpMod/Helpers/XyzTools.cs: a static class with [StructLayout(LayoutKind.Sequential)] header structs plus IsValidHeader() and any effect-conversion table.
  2. Add ParseXyzFile() to SharpMod.cs (loaders live on SoundFile, not in the Tools class).
  3. Populate instruments[], patterns[][], order[] and channels[], transcoding cells into the 6-byte S3M layout and padding to 64 rows per pattern.
  4. Add a Types enum member and wire detection into the ctor's sniffing chain — mind the ordering constraints above.
  5. Add loader tests in SharpMod.ConsolePlayer.Tests/ that build a synthetic file in memory and assert instrument metadata, loop points and cell encoding.
  6. Add the extension to supportedExtensions in Program.cs and to the WASM file input's accept list.

Adding a renderer component

  1. Add a file under Renderer/ with a static Render(SoundFile sf).
  2. Position with Console.SetCursorPosition(); style with Console.WriteInterpolated() and Color tokens.
  3. Cache anything that doesn't change every frame, and invalidate on song / terminal-size / option changes — follow the pattern in Samples.cs.

Modifying CLI arguments

Update the parsing logic and the Cli record in Cli.cs, extend PrintUsage(), thread the value through Program, and update the options table in SharpMod.ConsolePlayer/README.md.

Debugging audio playback

  • Inspect queue state with AL.GetSource(alSrc, ALGetSourcei.BuffersQueued, out int q).
  • Confirm SoundFile.Read() returns non-zero before end-of-song, and that the backend was opened with the same rate/bit-depth/channel count passed to the ctor.
  • Check the queue-depth target and the silent prime buffer.
  • On macOS see the OpenAL note under Gotchas.

Conventions

  • Formatting is enforced by .editorconfig and is non-default C#: braces on the same line, no space after control-flow keywords (if(x), for(...), while(...)), else/catch/finally on the same line as the closing brace, single-line statements and blocks preserved. Match it — a reformat pass on engine code produces enormous noise diffs.
  • Format helpers: SharpMod/Helpers/<Fmt>Tools.cs; loader methods: ParseXxxFile() on SoundFile in SharpMod.cs.
  • Mixing is fixed-point: MOD_PRECISION = 10, MOD_FRACMASK = 1023; sample positions are Q22.10.
  • Per-mix attenuation follows OpenMPT's PreAmpTable curve (indexed by channels >> 1), calibrated so 4 channels reproduces Mod95's original divisor of 32. Don't replace it with a linear divider — that over-attenuates dense many-channel songs and clamping instead wraps sums through the byte cast as crackling.
  • Tracker text is CP437, not UTF-8 — decode via LegacyEncoding.Cp437 (Helpers/ExtensionMethods.cs), which is why System.Text.Encoding.CodePages is referenced.
  • Comments in engine code exist to explain magic offsets, format quirks and deliberate deviations from OpenMPT/FT2 semantics. When you fix a compatibility bug, note which tracker's behaviour you matched — that's the established pattern and often the only record of why the code looks wrong.
  • AssemblyVersion/FileVersion are hand-bumped date-stamps (e.g. 2026.6.22.4) in each .csproj.
  • Run the tests before committing changes to loaders or effect handlers (once the InternalsVisibleTo fix above is in place).

Gotchas

  • supportedExtensions in Program.cs:6 is [".mod", ".stm", ".s3m", ".xm"]missing .669, so directory and glob playlist expansion silently skips 669 files the loader supports and the README advertises. Naming an individual 669 file still works.
  • SoundFile parses everything eagerly into memory; there is no streaming-from-disk playback. It disposes the input stream when it owns it (the string and byte[] ctors); the Stream ctor does not.
  • Restart dotnet run after editing anything under wwwroot/; the dev server does not hot-reload it. index.html is served from a build-time copy, so edits just don't show up. JS fails harder than that: the static-web-assets pipeline fingerprints modules and writes their build-time SHA-256 into the import map's integrity, so the browser blocks an edited file — Failed to find a valid digest in the 'integrity' attribute — the import fails and the app hangs on "Loading .NET runtime…" with nothing else logged. (CSS does seem to be served live, but restarting is the only rule worth remembering.)
  • macOS OpenAL: OpenTK.dll.config still maps openal32.dll to Apple's deprecated OpenAL.framework, which SIGSEGVs inside alGetSourcei under rapid queue/unqueue churn (e.g. spamming Home/End to switch tracks). The fix is manual and documented in that file: brew install openal-soft and retarget the two osx OpenAL entries at the resulting libopenal.dylib.
  • Release/mods/ is the go-to real-world corpus covering all five formats. It escapes the [Rr]elease/ ignore only via three anchored rules near the end of .gitignore (!/Release/, /Release/*, !/Release/mods/). The obvious !**/Release/mods one-liner that used to be there does nothing — git cannot re-include a path inside an excluded directory — so new mods were silently invisible to git status while the already-tracked ones kept working purely because .gitignore doesn't apply to files in the index. Don't collapse it back.

Links