diff --git a/docs/audio-input.md b/docs/audio-input.md index ef51d705..00321082 100644 --- a/docs/audio-input.md +++ b/docs/audio-input.md @@ -4,24 +4,27 @@ The runtime connects Ocarina's polling `nn::audio` input calls to SDL3's default microphone or audio interface as the operating system's default input and enable the game's microphone control. Both a hummed note and an instrument received by that input go through the game's own note recognition. -Audio capture opens when the game calls `OpenInputStream` and starts only on `StartInputStream`. The runtime does -not write recordings to disk. SDL converts the input device's format to the requested PCM format and sample rate. +At `OpenInputStream` the runtime checks that a default input exists and prepares a buffer. It opens the recording +device at `StartInputStream` and releases it at stop or close. After a stop, the game can still read or purge the +buffered frames and start capture again. Recordings stay in memory. SDL converts the input device's format to the +requested PCM format and sample rate. The capture callback uses a bounded ring: unread overflow drops the oldest complete frames instead of retaining an unlimited recording while the game is paused. Stop, purge and close are distinct operations. Purging an unopened or failed stream is safe, including the null-stream path that previously crashed in `InputStreamImpl::Purge`. This implements the non-event polling overload used by Ocarina 1.1.1. Event-based streams, per-device selection, hot-unplug recovery and packed 24-bit PCM are not implemented. Statistics return a neutral 24-byte structure. -Unsupported formats and a denied or missing recording device return an input error rather than reporting a -successfully opened stream. Input currently follows the operating system's default recording device; there is no -new runtime menu setting. +An unsupported format or missing default device returns an error at open. If the device disappears or access is +denied when capture starts, start returns an input error and the game can retry. Input follows the operating system's +default recording device; there is no new runtime menu setting. ## Validation `python tests/audio_input_check.py --toolchain C:/msys64/ucrt64` builds and runs headless checks using SDL's dummy -audio driver. They require no game files and do not open a physical microphone. They cover sample ordering across -ring wrap and overflow, frame counts, start/stop, repeated open/close, purge, unsupported formats and purge/read -after a failed open. On Linux, omit `--toolchain` and install SDL3's development package and pkg-config. +audio driver. They require no game files or physical microphone. They check that idle streams leave the device +closed, that a denied start can be retried, and that stopping one stream releases its device and preserves unread +frames. They also cover ring wrap and overflow, frame counts, repeated open/close, unsupported formats and +purge/read after a failed open. On Linux, omit `--toolchain` and install SDL3's development package and pkg-config. A separate local real-device check received mono signed PCM16 at 48 kHz, verified the 4800-frame ring remained bounded, and opened and closed the device three times. In an Ocarina 1.1.1 Windows session built from the 0.5.0 diff --git a/runtime/audio_capture.c b/runtime/audio_capture.c index 229bcafe..ad43d147 100644 --- a/runtime/audio_capture.c +++ b/runtime/audio_capture.c @@ -6,6 +6,7 @@ struct MicCapture { SDL_AudioStream *stream; + SDL_AudioDeviceID device; unsigned char *ring; int cap, head, used, bpf; }; @@ -95,24 +96,45 @@ MicCapture *mic_open(int format, int channels, int rate, int frames) { return NULL; } SDL_AudioSpec spec = {.format = sf, .channels = channels, .freq = rate}; - m->stream = SDL_OpenAudioDeviceStream(SDL_AUDIO_DEVICE_DEFAULT_RECORDING, &spec, received, m); - if (!m->stream) { - free(m->ring); - free(m); - SDL_QuitSubSystem(SDL_INIT_AUDIO); + SDL_AudioSpec source; + if (SDL_GetAudioDeviceFormat(SDL_AUDIO_DEVICE_DEFAULT_RECORDING, &source, NULL)) + m->stream = SDL_CreateAudioStream(&source, &spec); + if (!m->stream || !SDL_SetAudioStreamPutCallback(m->stream, received, m)) { + mic_close(m); return NULL; } return m; } -int mic_start(MicCapture *m) { return m && SDL_ResumeAudioStreamDevice(m->stream); } +int mic_start(MicCapture *m) { + if (!m) + return 0; + if (m->device) + return 1; + SDL_AudioSpec spec; + if (!SDL_GetAudioStreamFormat(m->stream, NULL, &spec)) + return 0; + SDL_AudioDeviceID device = SDL_OpenAudioDevice(SDL_AUDIO_DEVICE_DEFAULT_RECORDING, &spec); + if (!device) + return 0; + if (!SDL_BindAudioStream(device, m->stream)) { + SDL_CloseAudioDevice(device); + return 0; + } + m->device = device; + return 1; +} void mic_stop(MicCapture *m) { - if (m) - SDL_PauseAudioStreamDevice(m->stream); + if (m && m->device) { + SDL_CloseAudioDevice(m->device); /* unbinds the stream; unread frames remain in the ring */ + m->device = 0; + } } void mic_close(MicCapture *m) { if (!m) return; - SDL_DestroyAudioStream(m->stream); /* joins capture before freeing userdata */ + mic_stop(m); + if (m->stream) + SDL_DestroyAudioStream(m->stream); free(m->ring); free(m); SDL_QuitSubSystem(SDL_INIT_AUDIO); diff --git a/runtime/ov_audio_input.c b/runtime/ov_audio_input.c index df3b9e9a..cff46a05 100644 --- a/runtime/ov_audio_input.c +++ b/runtime/ov_audio_input.c @@ -86,7 +86,7 @@ static void open_input(Cpu *c) { } MicCapture *m = mic_open(f[0], f[1], f[2], frames); if (!m) { - rt_log("mic: cannot open the default recording device: %s", mic_error()); + rt_log("mic: cannot prepare the input stream: %s", mic_error()); ret_x(c, BAD_INPUT); return; } @@ -97,7 +97,7 @@ static void open_input(Cpu *c) { s->next_log = (uint64_t)f[2] * 5; uint64_t impl = key + 8; memcpy(ipc_guest(key, 8), &impl, 8); - rt_log("mic: default recording device opened; bounded buffer %d frames", mic_capacity(m)); + rt_log("mic: input stream ready; bounded buffer %d frames; device opens when capture starts", mic_capacity(m)); ret_x(c, 0); } static void start_input(Cpu *c) { @@ -115,6 +115,8 @@ static void stop_input(Cpu *c) { Input *s = find(c->x[0]); if (s) { mic_stop(s->mic); + if (s->running) + rt_log("mic: capture stopped; recording device released"); s->running = 0; } ret_x(c, 0); diff --git a/tests/audio_input_check.c b/tests/audio_input_check.c index 900f8ce9..cf34eeca 100644 --- a/tests/audio_input_check.c +++ b/tests/audio_input_check.c @@ -1,7 +1,21 @@ /* No game data or physical microphone: exercise conversion, bounded capture and the polling ABI. */ -#include "../runtime/audio_capture.c" -#include "../runtime/ov_audio_input.c" +#include #include + +static bool deny_recording; +static int device_open_calls; +static SDL_AudioDeviceID open_test_device(SDL_AudioDeviceID device, const SDL_AudioSpec *spec) { + device_open_calls++; + if (deny_recording) { + SDL_SetError("simulated recording permission denial"); + return 0; + } + return SDL_OpenAudioDevice(device, spec); +} +#define SDL_OpenAudioDevice open_test_device +#include "../runtime/audio_capture.c" +#undef SDL_OpenAudioDevice +#include "../runtime/ov_audio_input.c" #include #include #include @@ -20,6 +34,10 @@ void *ipc_guest(uint64_t addr, uint64_t size) { return (void *)(uintptr_t)addr; } void rt_log(const char *fmt, ...) { (void)fmt; } +int rt_knob(const char *name, const char *def) { + assert(strcmp(name, "MICROPHONE") == 0); + return atoi(def); +} static void ring_check(void) { assert(SDL_InitSubSystem(SDL_INIT_AUDIO)); @@ -59,17 +77,38 @@ static void polling_check(void) { c.x[5] = 4800; open_input_locked(&c); assert(c.x[0] == 0); + MicCapture *m = find(key)->mic; + assert(SDL_GetAudioStreamDevice(m->stream) == 0); c.x[0] = key; state_locked(&c); assert(c.x[0] == 1); + deny_recording = true; + c.x[0] = key; + start_input_locked(&c); + assert(c.x[0] == BAD_INPUT); + assert(SDL_GetAudioStreamDevice(m->stream) == 0); + c.x[0] = key; + state_locked(&c); + assert(c.x[0] == 1); + deny_recording = false; c.x[0] = key; start_input_locked(&c); assert(c.x[0] == 0); + SDL_AudioDeviceID active = SDL_GetAudioStreamDevice(m->stream); + assert(active); + c.x[0] = key; + start_input_locked(&c); + assert(c.x[0] == 0 && SDL_GetAudioStreamDevice(m->stream) == active); c.x[0] = key; state_locked(&c); assert(c.x[0] == 2); SDL_Delay(350); /* overflow the 100 ms ring, not system memory */ c.x[0] = key; + stop_input_locked(&c); + SDL_AudioSpec spec; + assert(!SDL_GetAudioDeviceFormat(active, &spec, NULL)); + assert(SDL_GetAudioStreamDevice(m->stream) == 0); + c.x[0] = key; filled_locked(&c); assert(c.x[0] > 0 && c.x[0] <= 4800); c.x[0] = key; @@ -78,6 +117,9 @@ static void polling_check(void) { read_input_locked(&c); assert(c.x[0] > 0 && c.x[0] <= 4800); c.x[0] = key; + start_input_locked(&c); + assert(c.x[0] == 0 && SDL_GetAudioStreamDevice(m->stream) != 0); + c.x[0] = key; stop_input_locked(&c); c.x[0] = key; purge_input_locked(&c); @@ -120,13 +162,34 @@ static void polling_check(void) { assert(mic_available(NULL) == 0); } +static void independent_streams_check(void) { + int previous_calls = device_open_calls; + MicCapture *a = mic_open(2, 1, 48000, 4800); + MicCapture *b = mic_open(2, 1, 48000, 4800); + assert(a && b); + assert(device_open_calls == previous_calls); + assert(SDL_GetAudioStreamDevice(a->stream) == 0 && SDL_GetAudioStreamDevice(b->stream) == 0); + assert(mic_start(a)); + assert(SDL_GetAudioStreamDevice(b->stream) == 0); + assert(mic_start(b)); + assert(device_open_calls == previous_calls + 2); + SDL_AudioDeviceID active = SDL_GetAudioStreamDevice(b->stream); + assert(active && active != SDL_GetAudioStreamDevice(a->stream)); + mic_close(a); + assert(SDL_GetAudioStreamDevice(b->stream) == active); + mic_stop(b); + assert(SDL_GetAudioStreamDevice(b->stream) == 0); + mic_close(b); +} + int main(void) { assert(SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy")); registry_lock = SDL_CreateMutex(); assert(registry_lock); ring_check(); polling_check(); + independent_streams_check(); SDL_DestroyMutex(registry_lock); - puts("PASS: sample order, bounded overflow, polling frame units, reopen, purge and failed-open regression"); + puts("PASS: bounded capture, idle streams, start/stop, failed start and retry, independent streams and purge"); return 0; }