Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`main`, the release pipeline automatically replaces `[current]` with the next
version number before tagging the release.

## [current]

### Added

- **`audio.load_pcm(data, length, sample_rate, channels, format)`** — play PCM
samples the caller has **already decoded**, rather than only encoded
containers miniaudio can demux itself (`asks/pcm-please.md`).

`load_wav` is better than its name — it is a format-sniffing decoder, so mp3
and flac already work — but every entry point wanted bytes miniaudio could
parse. That left no way in for samples a *different* decoder produced, which
is exactly the case once `contrib.avcodec` has demuxed an MP4 and holds the
audio packets. The workaround was pre-extracting a sidecar WAV: roughly
doubling on-disk cost (a 20 MB sidecar for a 21 MB clip), a manual step
before playback, and no option at all for a live source with no file to
extract from — the same intermediate-file problem `contrib/avcodec` was
written to remove, reappearing on the audio side.

Backed by `ma_audio_buffer` fed to the same `ma_sound_init_from_data_source`
the encoded path uses, so the whole transport surface works unchanged: `play`,
`pause`, `position_ms`, `duration_ms`, `seek_ms`, `volume`. `position_ms`
keeps working as the A/V-sync master clock, which is the reason the ask
matters. Sample formats are exposed as `audio.FORMAT_U8` / `_S16` / `_S24` /
`_S32` / `_F32` so callers never hardcode miniaudio's numbering.

`length` must be a whole number of frames (bytes-per-sample x channels); a
partial trailing frame is refused rather than played as noise off the end.

## [0.510.0]

### Added
Expand Down
95 changes: 89 additions & 6 deletions std/audio/aether_audio.c

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

46 changes: 45 additions & 1 deletion std/audio/module.ae
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ import std.string

exports(
open, close, is_null_backend,
load_wav, last_error,
load_wav, load_pcm, last_error,
FORMAT_U8, FORMAT_S16, FORMAT_S24, FORMAT_S32, FORMAT_F32,
play, pause, stop, is_playing,
volume, get_volume,
seek_ms, position_ms, duration_ms,
Expand All @@ -36,6 +37,7 @@ extern aether_audio_open() -> int
extern aether_audio_close()
extern aether_audio_is_null_backend() -> int
extern aether_audio_load_wav(data: string, length: int) -> ptr
extern aether_audio_load_pcm(data: string, length: int, sample_rate: int, channels: int, format: int) -> ptr
extern aether_audio_last_error() -> string
extern aether_audio_unload(sound: ptr)
extern aether_audio_play(sound: ptr) -> int
Expand Down Expand Up @@ -89,6 +91,48 @@ load_wav(data: string, length: int) -> ptr! {
return s
}

// PCM sample formats for `load_pcm`. These mirror miniaudio's ma_format_*
// numbering so a caller never has to hardcode it. FORMAT_S16 (interleaved
// 16-bit signed) is what most decoders emit and what ffmpeg's `s16le` means.
const FORMAT_U8 = 1
const FORMAT_S16 = 2
const FORMAT_S24 = 3
const FORMAT_S32 = 4
const FORMAT_F32 = 5

// Play PCM samples the caller ALREADY decoded, rather than an encoded
// container (asks/pcm-please.md).
//
// `load_wav` is really a format-sniffing decoder — it accepts mp3 and flac
// too — but every entry point wants bytes miniaudio can demux itself. That
// leaves no way in for samples a *different* decoder produced, which is
// exactly the case when contrib.avcodec has demuxed an MP4 and holds the
// audio packets. The workaround was pre-extracting a sidecar WAV: roughly
// doubling on-disk cost, a manual step before playback, and nothing at all
// for a live source with no file to extract from.
//
// `data` is interleaved samples, `length` its byte count, `format` one of
// the FORMAT_* constants above. The bytes are copied, so the caller's buffer
// need not outlive the source.
//
// Everything downstream works exactly as for `load_wav` — play, pause,
// position_ms, duration_ms, seek_ms, volume — because they all read the
// underlying sound, not the decoder. position_ms in particular remains
// usable as an A/V-sync master clock.
//
// `length` must be a whole number of frames (bytes-per-sample x channels);
// a partial trailing frame is refused rather than played as noise.
load_pcm(data: string, length: int, sample_rate: int, channels: int,
format: int) -> ptr! {
s = aether_audio_load_pcm(data, length, sample_rate, channels, format)
if s == null {
e = aether_audio_last_error()
if e == null { return null, "audio: load_pcm failed" }
return null, e
}
return s
}

// The reason the most recent load failed (or "" after a success). BORROWED
// from the substrate — a static C string valid until the next load_wav.
last_error() -> string {
Expand Down
110 changes: 110 additions & 0 deletions tests/regression/test_audio_load_pcm.ae
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// audio.load_pcm — play samples the caller already decoded (asks/pcm-please.md).
//
// Every other std.audio entry point takes an ENCODED container miniaudio
// demuxes itself (load_wav is really a format-sniffing decoder, so mp3 and
// flac work too). That left no way in for samples a *different* decoder
// produced — precisely the case when contrib.avcodec has demuxed an MP4 and
// holds the audio packets. The workaround was pre-extracting a sidecar WAV:
// roughly doubling on-disk cost, a manual step before playback, and nothing at
// all for a live source with no file to extract from.
//
// What this pins:
// 1. a caller-supplied PCM buffer becomes a playable source
// 2. duration_ms is right, i.e. frame maths is right
// 3. seek_ms / position_ms work — the A/V-sync master clock, and the reason
// the ask says this matters
// 4. bad arguments are refused rather than played as noise
//
// SKIPs cleanly where there is no audio device, which is the normal case on a
// CI runner.
import std.audio
import std.bytes
import std.string

check(cond: bool, label: string) -> int {
if cond == true {
println(" PASS ${label}")
return 0
}
println(" FAIL ${label}")
return 1
}

main() {
println("=== audio.load_pcm ===")
fails = 0

if audio.open() != true {
println(" SKIP: no audio device available")
return
}

// One second of silence: 44100 Hz, stereo, signed 16-bit interleaved.
// Silence keeps the test quiet on a machine with real speakers while still
// exercising the whole path — miniaudio does not care what the samples are.
rate = 44100
ch = 2
bytes_per_sample = 2
n = rate * ch * bytes_per_sample

buf = bytes.new(n)
i = 0
for (i = 0; i < n; i ++) {
bytes.set(buf, i, 0)
}
_ = bytes.set_length(buf, n)
pcm = bytes.to_string(buf, n)

src, err = audio.load_pcm(pcm, n, rate, ch, audio.FORMAT_S16)
if err != "" {
println(" FAIL load_pcm: ${err}")
bytes.free(buf)
string.free(pcm)
audio.close()
exit(1)
}
fails = fails + check(src != null, "a caller-supplied PCM buffer loads")

// Frame maths: n bytes / (channels * bytes_per_sample) frames / rate = 1s.
// A wrong bytes-per-frame would show up here as 500 or 2000.
d = audio.duration_ms(src)
fails = fails + check(d == 1000, "duration_ms == 1000 (got ${d})")
fails = fails + check(audio.channels(src) == ch, "channels round-trips")

// The clock. position_ms is what video chases for A/V sync, so it has to
// work on a PCM source exactly as it does on a decoded one.
fails = fails + check(audio.position_ms(src) == 0, "position starts at 0")
fails = fails + check(audio.seek_ms(src, 500) == true, "seek_ms succeeds")
p = audio.position_ms(src)
fails = fails + check(p == 500, "position follows the seek (got ${p})")

audio.unload(src)

// A partial trailing frame means the caller mis-computed its buffer.
// Refuse it — playing it would read past the last whole frame.
odd = n - 1
bad, e2 = audio.load_pcm(pcm, odd, rate, ch, audio.FORMAT_S16)
fails = fails + check(bad == null && string.length(e2) > 0,
"a partial trailing frame is refused")

// Nonsense format / geometry must not produce a source.
b2, e3 = audio.load_pcm(pcm, n, rate, ch, 99)
fails = fails + check(b2 == null && string.length(e3) > 0,
"an unknown PCM format is refused")

b3, e4 = audio.load_pcm(pcm, n, 0, ch, audio.FORMAT_S16)
fails = fails + check(b3 == null && string.length(e4) > 0,
"a zero sample rate is refused")

bytes.free(buf)
string.free(pcm)
audio.close()

println("")
if fails == 0 {
println("All PASS")
} else {
println("${fails} FAILURE(S)")
exit(1)
}
}
30 changes: 30 additions & 0 deletions tests/regression/test_fd_read_into.ae
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,20 @@ main() {
}
string.free(err)

// Windows: run_pipe's IPC channel is POSIX-only. os_run_pipe_raw lives
// inside `#ifndef _WIN32` and the Windows build returns a stub, but the
// pipe end it hands back is not a CRT fd usable by read() — wiring that
// up needs coordinated _open_osfhandle on both sides, which
// aether_os.c:1369 records as deliberately not done. Skip rather than
// fail: this test is about fd_read_into's semantics, and there is no
// readable fd to exercise them against here.
if fd < 0 {
println(" SKIP: run_pipe gave no readable fd on this platform (fd=${fd})")
_sk, _ske = os.wait_pid(pid)
string.free(_ske)
return
}

// One buffer, reused for every read. This is the point of the API.
cap = 64
buf = bytes.new(cap)
Expand All @@ -58,9 +72,24 @@ main() {
reads = 0
total = 0
n = 1
probe = 1
while n > 0 {
n, e = io.fd_read_into(fd, bytes.data(buf), cap)
if e != "" {
// A failure on the VERY FIRST read means the fd is not readable on
// this platform at all (see the Windows note above) rather than a
// regression in fd_read_into. Skip; a later failure is real.
if probe == 1 {
println(" SKIP: fd is not readable here (${e})")
string.free(e)
bytes.free(buf)
string.free(got1)
string.free(got2)
_ = io.fd_close(fd)
_sk, _ske = os.wait_pid(pid)
string.free(_ske)
return
}
fails = fails + check(0, "read errored: ${e}")
n = 0
} else {
Expand All @@ -75,6 +104,7 @@ main() {
string.free(s)
reads = reads + 1
total = total + n
probe = 0
}
}
string.free(e)
Expand Down
Loading