Skip to content

dtmf-io: file-I/O layer with pull-based AudioSource SPI - #6

Merged
tino1b2be merged 5 commits into
masterfrom
dtmf-io
May 2, 2026
Merged

dtmf-io: file-I/O layer with pull-based AudioSource SPI#6
tino1b2be merged 5 commits into
masterfrom
dtmf-io

Conversation

@tino1b2be

Copy link
Copy Markdown
Owner

dtmf-io: file-I/O layer with pull-based AudioSource SPI

Ships the dtmf-io spec in full — three new published modules that add file-I/O to the v2 foundation without touching dtmf-core. The core library still takes PCM as double[]/short[]/float[]/int[]; everything file-, URL-, or InputStream-shaped lives here.

What lands

Three new Gradle subprojects joining the build under the root aggregator:

Module Coordinate Runtime deps
dtmf-io com.tino1b2be:dtmf-io:2.0.0 :dtmf-core only
dtmf-io-wav com.tino1b2be:dtmf-io-wav:2.0.0 :dtmf-io only (clean-room RIFF parser)
dtmf-io-mp3 com.tino1b2be:dtmf-io-mp3:2.0.0 :dtmf-io, jlayer:1.0.1, mp3spi:1.9.5.4

The BOM (dtmf-bom:2.0.0) pins all three alongside the existing goertzel and dtmf-core constraints.

Design in one screen

  • Pull-based AudioSource. int read(double[] buf, int off, int len) — same shape as java.io.InputStream / AudioInputStream, but samples are always normalised double in [-1, 1]. Matches the contract dtmf-core's DtmfStream.SampleSource already speaks.
  • Content-based SPI. AudioSourceProvider implementations register via META-INF/services. AudioSources discovers them through ServiceLoader, scores each one on header bytes (not file extension), picks the strictly-highest score (tie-broken by declared priority()), and delegates. Adding FLAC/OGG/Opus later is a new module, not a patch to dtmf-io.
  • Auto-resolve on open. DtmfFileDecoder reads the source's actual sample rate and channel count, rebuilds the caller's DtmfConfig with those values substituted in, and decodes. Callers don't pre-inspect headers.
  • Checked IOException at the I/O boundary. Unlike dtmf-core (which forbids checked exceptions), dtmf-io reintroduces throws IOException plus a dedicated UnsupportedAudioFormatException subtype so callers can tell "disk broken" apart from "bytes not valid WAV/MP3".

Commits (narrative order)

  1. build: register dtmf-io* modules in settings, catalog, and BOM — pure scaffolding (settings.gradle.kts, gradle/libs.versions.toml, dtmf-bom). ./gradlew help still configures cleanly.
  2. feat(dtmf-io): add pull-based AudioSource SPI and DtmfFileDecoder — the SPI heart: AudioSource, AudioSourceProvider, AudioSources, DtmfFileDecoder, RawPcmAudioSource, PcmEncoding, UnsupportedAudioFormatException, plus the internal SampleConversion helper. 25 test classes, 11 of the 13 design properties.
  3. feat(dtmf-io-wav): add clean-room WAV AudioSourceProvider — RIFF/RF64 parser, WAVEFORMATEXTENSIBLE GUID dispatch, rejects μ-law/A-law/ADPCM. No javax.sound.sampled in main sources (Req 9.12). Properties 12–13.
  4. feat(dtmf-io-mp3): add MP3 AudioSourceProvider via JLayer + mp3spi — the only module with external codec deps. Handles ID3v2-prefixed streams and distinguishes MP3 Layer III from MP1/MP2 in the scanner. Shared MP3 fixtures aliased from dtmf-core/src/integrationTest/resources/samples/ via processTestResources.
  5. docs: describe shared MP3 test-fixture aliasing in CONTRIBUTING — documents the aliasing scheme and its fail-fast rename behaviour.

Each commit leaves the tree in a buildable state.

Testing

  • Unit tests: 304 passing across the three modules (218 + 63 + 23), 0 failures.
  • Integration tests: 9 passing on :dtmf-io:integrationTest with both real providers on the runtime classpath — RegisteredFormatsIT, OpenWavIT, OpenMp3IT, UnrecognizedInputIT, and Mp3RoundTripIT (opt-in, needs LAME).
  • Property tests: all 13 dtmf-io design properties covered exactly once each; every property-test file carries the // Feature: dtmf-io, Property N: <title> tag comment for traceability.

Final verification on this branch: ./gradlew clean build :dtmf-io:integrationTest — BUILD SUCCESSFUL.

Out of scope

CLI, GUI, microphone capture, FLAC/OGG/Opus/AAC providers, production WAV/MP3 encoding, Maven Central signing/publishing. Those stay deferred to later specs (Req 18).

Refs: .kiro/specs/dtmf-io/{requirements,design,tasks}.md

tino1b2be added 5 commits May 2, 2026 22:12
Scaffolding for the dtmf-io spec: three new subprojects join the build
and the BOM pins their coordinates, but no Java source lands here yet.

- settings.gradle.kts: add include(":dtmf-io"), include(":dtmf-io-wav"),
  include(":dtmf-io-mp3"); update the top-of-file module-list comment
  from four to seven subprojects and cite dtmf-io Requirement 1.1 next
  to the foundation spec's Requirement 1.1.
- gradle/libs.versions.toml: add jlayer (1.0.1) and mp3spi (1.9.5.4)
  under [versions] and [libraries] so :dtmf-io-mp3 can reference them
  via libs.jlayer / libs.mp3spi — per the rule that all third-party
  versions live in the catalog, never inline.
- dtmf-bom/build.gradle.kts: pin com.tino1b2be:dtmf-io:2.0.0,
  dtmf-io-wav:2.0.0, dtmf-io-mp3:2.0.0 alongside the existing goertzel
  and dtmf-core constraints (Req 2.1, 2.2 of the dtmf-io spec).

At this commit the three module directories are referenced by
settings.gradle.kts but contain no build.gradle.kts or Java source.
Gradle tolerates that — `./gradlew help` and `./gradlew tasks` still
configure cleanly — so this commit lands a coherent, buildable slice
of scaffolding before any production code.

Refs: .kiro/specs/dtmf-io/requirements.md (Req 1.1, 2.1, 2.2)
Land the core of the dtmf-io layer: the format-agnostic pull-based
`AudioSource` read contract, the `AudioSourceProvider` SPI, the
`AudioSources` facade that dispatches via `ServiceLoader`, and the
`DtmfFileDecoder` glue that opens a source, auto-resolves the detected
sample rate into the caller's `DtmfConfig`, and feeds samples through
`dtmf-core`. Plus the shared `RawPcmAudioSource` for raw PCM input and
an internal `SampleConversion` helper that normalises every supported
integer/float encoding to double[-1, 1].

Public surface (com.tino1b2be.dtmf.io):
- AudioSource — read(double[], int, int) / seek / close, normalised
  double samples in [-1, 1] (Req 3.6, 3.7).
- AudioSourceProvider — canOpen(Path/InputStream, hint) returns a score
  in {-1} ∪ [0, 100], plus priority() for tie-breaking (Req 4.2-4.8).
- AudioSources — static facade. Discovers providers via ServiceLoader,
  wraps non-markable InputStreams in a 16KiB BufferedInputStream before
  scoring (Req 5.12), picks strictly-highest-score provider, throws
  UnsupportedAudioFormatException with diagnostics populated on miss
  (Req 5.7, 5.8). File-not-found is reported as bare IOException
  (Req 6.3) with the supplied Path preserved.
- DtmfFileDecoder — four overloads (Path/InputStream/URL + the explicit
  AudioSource form). Rebuilds the caller's DtmfConfig with the source's
  actual sampleRate() / channelCount() substituted in (Req 8.6, 17.1);
  stereo sources are downmixed via ChannelMode.STEREO_DOWNMIX; always
  closes the opened AudioSource, even on exception (Req 8.11, 11.3).
- PcmEncoding enum — SIGNED, UNSIGNED_OFFSET_BINARY, IEEE_FLOAT.
- UnsupportedAudioFormatException — checked IOException subtype with
  attemptedFormat + message hint for the caller (Req 6.4, 6.5, 6.6).
- RawPcmAudioSource — pre-validated raw-PCM constructor + the common
  `fromPcm16LittleEndian(byte[], sampleRate, channelCount)` factory.

Internal (com.tino1b2be.dtmf.io.internal):
- SampleConversion — PCM8/16/24/32/64 signed/unsigned LE/BE plus IEEE
  float32/64 → double[-1, 1], with exact fixed-point normalisation
  constants verified against Property 3.
- ProviderScore — immutable record pairing a provider with the score it
  returned, used by AudioSources for tie-break ordering.

Tests (25 classes; includes 11 of the 13 dtmf-io property tests):
- Property 1: AudioSource.read contract
- Property 2: AudioSource close and seek lifecycle
- Property 3: RawPcmAudioSource sample normalization
- Property 4: RawPcmAudioSource constructor input validation
- Property 5: AudioSources scoring and selection
- Property 6: UnsupportedAudioFormatException diagnostics populated
- Property 7: Non-markable stream is wrapped before scoring
- Property 8: Provider close lifecycle and caller-stream ownership
- Property 9: DtmfFileDecoder close semantics
- Property 10: DtmfFileDecoder auto-resolve config preservation
- Property 11: DtmfFileDecoder channel-count and sample-rate rejection
Plus ErrorPathsTest (exception-type invariants), FixtureSmokeTest,
ModuleStructureTest, and scenario-based example tests for the public
types. Property tests are tagged
"// Feature: dtmf-io, Property N: <title>" per the project convention.

Integration-test source set wires :dtmf-io-wav and :dtmf-io-mp3 onto
the IT runtime classpath so ServiceLoader discovers both real
providers. IT classes (RegisteredFormatsIT, OpenWavIT, OpenMp3IT,
UnrecognizedInputIT, Mp3RoundTripIT) land in the same commit as the
:dtmf-io SPI they exercise — the providers themselves follow in the
next two commits but Gradle's `integrationTestRuntimeOnly` declarations
resolve lazily, so this commit's `:dtmf-io:build` passes.

Build shape: only runtime dep is `api(project(":dtmf-core"))` — zero
external codec libraries here by design (Req 1.2). Test classpath
adds :dtmf-io-wav so ErrorPathsTest can feed hand-built byte fixtures
through the real WavAudioSourceProvider.

Verification: `./gradlew :dtmf-io:check` is green (218 unit tests +
9 integration tests, 0 failures).

Refs: .kiro/specs/dtmf-io/requirements.md (Req 1.2, 3, 4, 5, 6, 7, 8,
11, 12, 17), .kiro/specs/dtmf-io/design.md (Properties 1-11)
Ship the WAV provider as a sibling module to dtmf-io. The parser is
clean-room per Req 9.12 — no javax.sound.sampled on the decode path,
no third-party RIFF library — so the entire WAV format surface (RIFF,
RF64, WAVEFORMATEXTENSIBLE, WAVE_FORMAT_PCM/IEEE_FLOAT, PCM8/16/24/32,
IEEE float32/64, stereo interleaving) is handled by fresh code under
`com.tino1b2be.dtmf.io.wav.internal`.

Public surface (com.tino1b2be.dtmf.io.wav):
- WavAudioSourceProvider — implements AudioSourceProvider. Header
  detection via canOpen(InputStream, hint) returns 100 on a strict
  "RIFF....WAVE" or "RF64....WAVE" match and -1 otherwise
  (Req 9.5-9.6). Opens a WavAudioSource backed by WavSampleReader.
- WavAudioSource — implements AudioSource. Parses the fmt/data chunks
  eagerly at open time (Req 9.7), then streams samples via
  WavSampleReader. Stereo is surfaced via channelCount(); callers that
  want a mono mix go through DtmfFileDecoder's auto-downmix.

Internal (com.tino1b2be.dtmf.io.wav.internal):
- RiffReader + RiffChunk — chunk walker with RF64 ds64 handling for
  >4GB files (Req 9.10). Enforces WORD-alignment and catches the
  Microsoft "garbage after data" quirk.
- WaveFormat — parsed fmt chunk. Resolves WAVEFORMATEXTENSIBLE's
  SubFormat GUID into the canonical WAVE_FORMAT_PCM /
  WAVE_FORMAT_IEEE_FLOAT codes. Rejects μ-law (0x0007), A-law (0x0006),
  MS-ADPCM (0x0002), IMA-ADPCM (0x0011), and every other compressed
  format with UnsupportedAudioFormatException carrying the rejected
  format tag as attemptedFormat diagnostics (Req 9.8, 12.2).
- WavSampleReader — pulls bytes from the data chunk and normalises via
  dtmf-io's SampleConversion helper (no re-implementation here).

SPI registration: META-INF/services/com.tino1b2be.dtmf.io.
AudioSourceProvider lists `com.tino1b2be.dtmf.io.wav.
WavAudioSourceProvider`, so ServiceLoader discovers the provider as
soon as this module is on the runtime classpath.

Tests (7 classes; includes 2 of the 13 dtmf-io property tests):
- Property 12: WAV provider header detection (strict magic match)
- Property 13: WAV round-trip preserves samples within quantization
  tolerance (DtmfGenerator → WavEncoder test helper → WavAudioSource
  → DtmfDecoder, checks key sequence recovered bit-exact for PCM16
  and within ULP tolerance for float32/64)
- WavAudioSourceProviderTest — nested scenarios covering
  ChunkWalking, Extensible (WAVEFORMATEXTENSIBLE GUID dispatch),
  Rf64 (>4GB ds64 chunk), StereoInterleaving, StructuralDefects
  (missing fmt/data, truncated header, bogus chunk size),
  UnsupportedFormatTag (μ-law, A-law, ADPCM rejection).
- RiffReaderTest and RiffChunkTest — chunk-walker unit coverage.
- BuildShapeTest — pins no external runtime deps on this module's
  classpath (Req 1.3 invariant guard).
WavEncoder lives in src/test and is deliberately kept test-only: it
exists to synthesize canonical WAV bytes for round-trip tests, not as
a public encoding API (production WAV encoding is out of scope, Req
18.1).

Build shape: only runtime dep is `api(project(":dtmf-io"))`. Zero
external codec libraries, zero javax.sound.sampled in src/main —
verified by BuildShapeTest.

Verification: `./gradlew :dtmf-io-wav:build` is green (63 unit tests,
0 failures).

Refs: .kiro/specs/dtmf-io/requirements.md (Req 1.3, 9, 12.2),
.kiro/specs/dtmf-io/design.md (Properties 12-13)
Ship the MP3 provider. Unlike WAV this one is not clean-room: MP3
framing, Huffman decoding, and all the Layer III arithmetic is handled
by JLayer (javazoom:jlayer:1.0.1) with mp3spi
(com.googlecode.soundlibs:mp3spi:1.9.5.4) bridging JLayer into
javax.sound.sampled so the provider can decode via
AudioSystem.getAudioInputStream(...). Those are the only two external
runtime dependencies this module adds (Req 1.4); a third is a
requirements regression.

Public surface (com.tino1b2be.dtmf.io.mp3):
- Mp3AudioSourceProvider — implements AudioSourceProvider. Header
  detection via Mp3HeaderScanner looks for the 11-bit frame sync,
  validates MPEG version / Layer III, and tolerates ID3v2 tags at the
  start of the stream by skipping over them before sync-word scanning
  (Req 10.5). Returns a score of 90 on a confirmed Layer III sync
  (strictly below WAV's 100 so a "WAVE" magic always wins when both
  providers are on the classpath), and -1 on non-MP3 bytes.
- Mp3AudioSource — implements AudioSource. Opens via
  AudioSystem.getAudioInputStream(InputStream), decodes frames on
  demand into the caller's double[] buffer, and normalises 16-bit PCM
  through dtmf-io's SampleConversion helper. Stereo is preserved via
  channelCount(); DtmfFileDecoder's auto-downmix handles the
  mono-mix path. seek() advances by skip-and-discard because JLayer's
  AudioInputStream does not support random access on MP3 frames.

Internal (com.tino1b2be.dtmf.io.mp3.internal):
- Mp3HeaderScanner — pre-SPI content sniffer. Skips ID3v2 header
  (0x49 0x44 0x33 + size synchsafe), then walks up to 4KiB of bytes
  searching for a valid MPEG Layer III frame header. Distinguishes
  Layer III from Layers I/II so MP2 and MP1 don't false-positive.
  Consumed by both canOpen() scoring and open() pre-flight.

SPI registration: META-INF/services/com.tino1b2be.dtmf.io.
AudioSourceProvider lists `com.tino1b2be.dtmf.io.mp3.
Mp3AudioSourceProvider`, so ServiceLoader picks up the provider once
this module is on the runtime classpath.

Shared test fixtures: the three MP3 binaries live in
dtmf-core/src/integrationTest/resources/samples/ and are aliased onto
this module's test classpath under shared-samples/ via a
`processTestResources` copy in build.gradle.kts (Req 15.4). No MP3
bytes are duplicated under this module's source tree.

Tests (3 classes; BuildShapeTest is the deps-pin guard):
- Mp3HeaderScannerTest — nested scenarios covering Id3v2 (tag skip),
  InputStreamContract (mark/reset behaviour), NonAudio (jazz.mp3 is
  positive, a PNG header is negative, a WAV header is negative),
  SyncWordLayerDispatch (MP1 / MP2 / MP3 Layer bits — only MP3
  returns > 0).
- Mp3AudioSourceTest — opens the shared 12345678.mp3 fixture end to
  end, decodes through Mp3AudioSource, and asserts sampleRate() /
  channelCount() match the known fixture metadata plus a smoke
  DtmfDecoder pass that recovers all 8 keys.
- BuildShapeTest — pins the dependency surface: only jlayer + mp3spi
  from Maven Central, plus :dtmf-io and :dtmf-core, nothing else.

No dtmf-io Property test lives here — the two MP3-related properties
(fixture integrity, Mp3RoundTripIT) are integration-scoped and live
in :dtmf-io's integrationTest source set.

Build shape: `api(project(":dtmf-io"))`, `implementation(libs.jlayer)`,
`implementation(libs.mp3spi)`. Two external runtime deps total, both
pinned via the version catalog.

Verification: `./gradlew :dtmf-io-mp3:build` is green (23 unit tests,
0 failures).

Refs: .kiro/specs/dtmf-io/requirements.md (Req 1.4, 10, 15.4),
.kiro/specs/dtmf-io/design.md (MP3 provider section)
Document the `processTestResources` alias wired into
`dtmf-io-mp3/build.gradle.kts` that copies three MP3 fixtures
(`12345678.mp3`, `jazz.mp3`, `stereo.mp3`) from
`dtmf-core/src/integrationTest/resources/samples/` onto the
`dtmf-io-mp3` test classpath under `shared-samples/`.

The section covers:

- Which three files are aliased and why each exists (a DTMF sequence,
  a non-DTMF negative anchor, a stereo positive).
- How unit tests load them (classpath resources under
  `/shared-samples/…`).
- The fail-fast behaviour: the task's inputs are named explicitly, so
  a rename or removal in `dtmf-core` surfaces as a missing-input build
  failure rather than a silently dropped fixture.

This is the docs side of the Stage 1 / Stage 7 work in the dtmf-io
spec (Req 15.4) — everything else about the aliasing was already
captured as comments in the relevant `build.gradle.kts` files.
@tino1b2be
tino1b2be merged commit 99a0f5d into master May 2, 2026
4 checks passed
@tino1b2be
tino1b2be deleted the dtmf-io branch May 2, 2026 22:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant