Feed the microphone to the ocarina's input streams, with a picker in the Insert menu #937
No reviewers
Labels
No labels
accessibility
autorelease: pending
autorelease: tagged
bug
dependencies
documentation
duplicate
enhancement
github_actions
good first issue
help wanted
invalid
question
testing wanted
wontfix
No milestone
No assignees
1 participant
Due date
No due date set.
Dependencies
No dependencies set
Reference
MagicBeanDev/Hookshot!937
Loading…
Reference in a new issue
No description provided.
Delete branch "audio/microphone"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What this adds
The ocarina's microphone mode (play an instrument, or hum) didn't work: the game's microphone functions were placeholder stubs, so the game never received any sound. This PR implements them on SDL's recording devices (SDL3's, or SDL2's capture devices, as the Linux AppImage carries SDL2), and adds a Microphone row to the Insert menu's Gameplay tab:
The choice applies straight away and is saved in
hookshot_options.iniby device name (microphone_device=), so it survives devices being reordered.What the game does (from how it calls these functions)
nn::audio::ListVirtualInputDevices. With the stub returning 0 devices, it never opened an input stream.GetTrueRawAudioInputDevice.StreamFormat{PcmInt16, 1 channel, 48000 Hz}and a capacity of 4800 frames.StartInputStreamwhenGetInputStreamState != 2, so 2 means started.GetInputStreamFilledFrameCount, thenReadInputStreamfor up to 256 frames.GetInputStreamStatistics(stream, true). The result comes back through x8, and the game checks the second 64-bit word against 1.Implementation
host/nn_audio_in.c(new):ListVirtualInputDevices(always 1 device, as on the console),GetDefaultVirtualInputDevice,GetTrueRawAudioInputDeviceandIsVirtualInputDeviceStreamFormatSupported.Open(the overload the game uses),Close,Start,Stop,Purge,GetState,Get{Filled,Free,Capacity}FrameCount,Read,GetStatistics(all zeros: nothing lost), and gain get/set.InputStreamType, asnn_audio.cdoes for its own objects.host/hos_sdl.c/.h:hos_sdl_mic_*, loaded as optional: on SDL3 a recording stream per microphone; on SDL2 (the Linux AppImage's) a capture device whose callback fills a ring of about a second, read under the device's lock. Both convert to the stream's format.gpu/vk/vk_api.h:MkwVkHostgainsmic_count,mic_nameandmic_select(the menu's choice: off, the default, or a microphone by its whole name and place in the list), andMKWVK_API_VERSIONgoes up by one.host/rx_backend.c: connectshos_mic_count,hos_mic_nameandhos_mic_selectto the menu.gpu/vk/vk_menu.cpp,vk_menu.h:readme.md: the row in the Insert menu's table, and a note under it.HOS_MIC_LOG=1(off by default, documented at the top ofnn_audio_in.cand in the readme) logs a line a second while a stream runs: frames read and peak level.Testing
tests/test_audio_in.py(run bytests/run_all.py, with--cc gccas the other host C checks). It compileshost/nn_audio_in.cwith stand-ins for SDL, the menu and the clock, and drives the streams as the game does. It covers the device list, open, start (state 2), silence at the stream's rate while Off, the microphone's samples once a device is picked, dropping past the capacity, purge, stop and close, a shared source, two devices with one name, names past 60 bytes, a device pulled out that keeps sending zeros, one that stalls, and the retries (46 cases). It also builds the menu's microphone code fromvk_menu.cppand checks duplicates, unplug and replug, Off/Default, and the UTF-8 cut.Notes
Fixes #189
Fun feature! A few issues, the first three being real bugs in the device handling: same-name devices, a mic unplugged mid-game, and the saved device being lost when it's missing at start. The rest are smaller.
The chosen mic is matched by name and the first match wins, so of two devices with the same name (two
USB Audio Devices is common) the second can never stay selected: pick it (opts.mic=3), and the next menu open (or the start'srefresh_mics(&mic_saved_name)) moves it back to index 0, quietly switching the ocarina to the other mic. Preferring the current index when its name still matches, and only searching otherwise, would fix it.@ -426,6 +461,52 @@ int detect_pad_layout() {if (a == HOS_PAD_SOUTH && b == HOS_PAD_EAST && x == HOS_PAD_NORTH && y == HOS_PAD_WEST) return 2;return 3;}If the saved mic is absent at start,
refresh_micsfalls back to Default (opts.mic = 1), and the nextsave()(any option change) writes nomicrophone_deviceline, so the choice is lost for good: plug the headset back in and the game stays on Default. Keepingmic_saved_nameand writing it back while the device is missing would preserve it.@ -429,0 +490,4 @@if (mic_key(host->mic_name(i)) == mic_want) at = i;opts.mic = at >= 0 ? at + 2 : 2 + mic_listed;} else if (opts.mic >= 2) {opts.mic = 1;nit: this chain of "not the preset's" pointer comparisons grows by one per non-graphics option, and forgetting one silently resets the preset to Custom. A
preset_exemptflag onRow, set in therows[]table, would scale better.This reader got a bigger buffer for
microphone_device, but the ini's other readers still usechar line[80](host/nn_hid.cbinds_load,host/nn_applet.c,host/hos_main.c's ultrawide read). A long device name (PipeWire/Windows names easily pass 80 chars) gets split byfgetsand its tail read as a separate line, andnn_applet.c's key reader has no whole-line guard. Bumping those buffers too (or skipping over-long lines) would avoid misparses.nit: option
18is a bare number here, innn_audio_in.c(rx_option(18, 1)), thevk_api.hcomment and the test. Adding it to theHOS_OPT_*enum like its neighbours would keep them from drifting apart.@ -0,0 +1,501 @@/* (hookshot) nn::audio's input streams: the microphone, as the ocarina's "play with your voice / an instrument" option uses it.Once the mic is open, this only reports what SDL has buffered. If the device is unplugged mid-game,
hos_sdl_mic_availablereturns 0 forever andReadInputStreamreturns 0 frames each frame instead of the promised steady silence, until the user happens to open the Insert menu. Likewise a first open that fails (device busy) is never retried. Falling back to silence when the device stops delivering (or SDL reports it removed), and retrying the open now and then, would cover both.The game starts both its default and its raw input stream, and each opens the mic itself, so the same physical device is opened twice: twice the conversion work, and on some SDL2 backends a second open of a capture device fails, leaving one stream silent. Opening it once and feeding both streams would be safer.
This overload takes the
os::SystemEventbut never signals it. If the game waits on that event beforeReadInputStream, it will block (or never be woken). Is the event version used by OoT? If so, it should be signalled when frames are available.hos_mic_countre-enumerates the devices while holdingmic_lock, and the menu calls it on the render thread every time the menu opens. On SDL2, re-detecting capture devices can take tens of ms with WASAPI/PulseAudio, which stalls the presented frame, and the game's audio-in calls block on the same lock meanwhile. Enumerating outside the lock and swapping the list in under it would avoid both.nit: the lazily initialised
CRITICAL_SECTIONwith a spin onlock_statecan be replaced bystatic SRWLOCK lock = SRWLOCK_INIT;withAcquireSRWLockExclusive/ReleaseSRWLockExclusive, ashos_thread.c/hos_svc.calready do. No init race to manage.Fixed. The row keeps its place when the device there still has the chosen name, so the second of two same-named microphones stays chosen across menu opens and restarts. The host now opens the exact device chosen (by the id it was chosen as), not the first of its name, and only falls back to the first of its name when that device is gone and comes back under a new id.
tests/test_audio_in.pycovers both sides with two same-named devices. I only have one microphone, so I haven’t been able to try two same-named ones on real hardware.Also, does this support mics on controllers, e.g. Switch Pro Controller?
@ -426,6 +461,52 @@ int detect_pad_layout() {if (a == HOS_PAD_SOUTH && b == HOS_PAD_EAST && x == HOS_PAD_NORTH && y == HOS_PAD_WEST) return 2;return 3;}Fixed: the chosen name is kept while the device is missing. Default stands in, the row says “(name: not found)”, the name stays in the ini, and it’s re-selected when the device is back.
@ -0,0 +1,501 @@/* (hookshot) nn::audio's input streams: the microphone, as the ocarina's "play with your voice / an instrument" option uses it.Fixed: a microphone that sends nothing for 2 s is closed, the streams get silence at their rate, and the open is retried every 3 s, by name. A failed first open goes the same way.
Thanks for the quick turnaround, this is much better: the shared source, the SRW lock,
HOS_OPT_MIC,preset_exempt, keeping the saved name and the 60-byte cut all look good to me. Dropping the SystemEvent overload makes sense too. A few things in the new retry path still stand out, mainly the first two.nit: cutting at 60 bytes can split a UTF-8 character (non-ASCII device names get a broken character in the ini and in "(name: not found)"). Backing up to a character boundary would avoid that.
refresh_micsonly runs when the menu opens, which gives two different behaviours for the same unplug. With the menu closed, the host keeps retrying the missing mic and the game gets silence (not the default the readme mentions). With the menu opened meanwhile, the row falls back to Default (choice 1), so the host stops wanting the mic and it never comes back on its own when plugged in again. Having the host fall back to the default device while the chosen one is missing, and keep retrying the chosen one, would match the readme in both cases.@ -335,0 +426,4 @@/* (no changes allowed: SDL converts the device's sound to this. SDL2's id is a place in its list, so the caller takesit from a list made just before (host/nn_audio_in.c find_device). SDL2 opens a device by its name: of two with onename it takes the first, whichever was chosen; telling them apart is SDL3's) */const char *name = id ? ((const char *(*)(int, int))rec.DeviceName2)((int)id - 1, 1) : NULL;On SDL2 (the Linux AppImage) this opens the device by name, so with two same-named mics SDL opens the first one even though
want_idpoints at the second: the menu says one mic, the game hears the other. That's an SDL2 limitation, so maybe just worth a comment, or noting that the same-name case is SDL3-only.@ -0,0 +1,501 @@/* (hookshot) nn::audio's input streams: the microphone, as the ocarina's "play with your voice / an instrument" option uses it.The retry looks in the cached list first and only lists again when the name isn't there. After an unplug/replug, the cached list (only refreshed by
hos_mic_count, i.e. on menu open) still has the mic's name with its old id, sopickreturns that id,list_nownever runs, andhos_sdl_mic_open(old id)fails every 3 s until the menu is opened. On SDL2 the id is an index, so it can open whichever device now sits at that index. Listing again when the cached id fails to open (or always on a retry) would fix it.With no recording device at all (choice 1, the default), this retries every 3 s for the whole session once the streams are started:
hos_sdl_mic_openruns on the game's thread under the exclusive lock (a WASAPI activation can take tens of ms), andhos_sdl.clogs "sdl: the microphone did not open" on every attempt. Thefailuresgate here only covers this file's own line. Backing the retry off (e.g. doubling up to a minute) and gating the SDL log line too would avoid the stalls and the log growth.A device that opens but never sends data (a failed
ResumeAudioStreamDeviceis only logged) loops forever: 2 s of quiet closes it, the 3 s retry reopens it, andfailuresis reset to 0 on the successful open, so it logs two lines and reopens the device every ~5 s. Not resettingfailuresuntil data has actually been heard would keep it quiet.nit:
silence_from_nsis never moved forward while a source stays silent, so(now - silence_from_ns) * rateoverflows int64 after ~53 h at 48 kHz. Unlikely, but rebasingsilence_from_ns/silence_madeevery so often (or computing in seconds + remainder) avoids it.minor: if the game stops polling for a while, the whole SDL backlog is pushed through every stream ring in 4 KB pieces under the lock, though only the newest capacity is kept. Dropping everything beyond the largest stream capacity first would make catching up cheap.
Thanks, the menu side looks right now, and the exact id fixes it on SDL3. On SDL2 the open is still by name (see the new comment on
hos_sdl.c), so there it still opens the first of the two.@ -426,6 +461,52 @@ int detect_pad_layout() {if (a == HOS_PAD_SOUTH && b == HOS_PAD_EAST && x == HOS_PAD_NORTH && y == HOS_PAD_WEST) return 2;return 3;}Confirmed, keeping
mic_wantand writing it back while the device is missing works. Thanks!@ -0,0 +1,501 @@/* (hookshot) nn::audio's input streams: the microphone, as the ocarina's "play with your voice / an instrument" option uses it.Fixed: started streams of the same format share one source, opened once, and each stream gets its own buffer of what it records.
Went the other way: microphone_device= is cut to 60 bytes, so the line always fits the 80-byte readers without touching them. Names are matched on that prefix.
@ -429,0 +490,4 @@if (mic_key(host->mic_name(i)) == mic_want) at = i;opts.mic = at >= 0 ? at + 2 : 2 + mic_listed;} else if (opts.mic >= 2) {opts.mic = 1;Done: Row::preset_exempt, set on the existing exempt rows and the Microphone row. test_mouse_settings and test_gpu_choice now check the flag.
Done: HOS_OPT_MIC in vk_api.h.
@ -0,0 +1,501 @@/* (hookshot) nn::audio's input streams: the microphone, as the ocarina's "play with your voice / an instrument" option uses it.Done: static SRWLOCK_INIT locks.
@ -0,0 +1,501 @@/* (hookshot) nn::audio's input streams: the microphone, as the ocarina's "play with your voice / an instrument" option uses it.Fixed: enumeration happens with no lock held, and the device list has its own lock, separate from the streams’.
@ -0,0 +1,501 @@/* (hookshot) nn::audio's input streams: the microphone, as the ocarina's "play with your voice / an instrument" option uses it.OoT only imports the plain overload, so I removed the event one. Its stub will log a call if anything ever uses it.
@ -0,0 +1,501 @@/* (hookshot) nn::audio's input streams: the microphone, as the ocarina's "play with your voice / an instrument" option uses it.Fixed: a retry always looks the device up in a freshly made list, so a re-plugged device under a new id (or a new SDL2 index) is found without opening the menu.
@ -0,0 +1,501 @@/* (hookshot) nn::audio's input streams: the microphone, as the ocarina's "play with your voice / an instrument" option uses it.Fixed: retries back off from 3 s, doubling up to 60 s, including for the default. hos_sdl_mic_open no longer logs; the caller logs once with hos_sdl_error(). The open still runs on the game’s thread, but at most once a minute when nothing is there. Moving it to a worker could be a follow-up.
@ -0,0 +1,501 @@/* (hookshot) nn::audio's input streams: the microphone, as the ocarina's "play with your voice / an instrument" option uses it.Fixed: the counter and the waits only start over when the wanted microphone actually gives sound, so a silent device backs off like a missing one and is logged once. A device whose ResumeAudioStreamDevice fails now counts as not opened.
Fixed by telling the host the choice by name (mic_select) instead of option 18. An unplugged choice stays chosen as a ‘(not connected)’ entry, and the host lets the default stand in and reconnects on its own, with or without the menu.
@ -0,0 +1,501 @@/* (hookshot) nn::audio's input streams: the microphone, as the ocarina's "play with your voice / an instrument" option uses it.Fixed: the start moves forward in whole seconds, and the sum is done in seconds plus the rest.
Fixed: cut at a character’s start.
@ -0,0 +1,501 @@/* (hookshot) nn::audio's input streams: the microphone, as the ocarina's "play with your voice / an instrument" option uses it.Fixed: the backlog beyond the largest stream’s capacity is read and dropped before anything is copied into the streams.
Thanks, the retry path is a lot more solid now: the fresh list on retries, the backoff with one log line per streak,
failuresresetting only when sound arrives, the default standing in while the chosen mic is missing, and the silence clock all look good. One regression from the name cut stands out, plus a few gaps in the unplug handling.This now hands the host
mic_want, which is cut tokMicNameKept(60) bytes, but the host still compares it to the full SDL name withstrcmp(nn_audio_in.cpickat 129, and thewant_idcheck at 205). So any mic whose name is over 60 bytes is never found. For example "Microphone Array (Intel® Smart Sound Technology for Digital Microphones)" is 72 bytes. The host logs it as not plugged in, the default stands in, and retries back off to every 60 s, while the menu shows it as chosen and connected. Before this commit the host took the full name fromdev_names. Passing the fullhost->mic_name(index)when the mic is listed (keeping the cut only for the ini), or matching by prefix whenwantis exactlykMicNameKeptlong, would fix it.minor:
refresh_mics()now runs on every start, not just when a mic name was saved, so every launch with an ini loads SDL audio and lists recording devices on the render thread during start-up, even with the mic Off or on Default. Calling onlymic_tell_host()whenmic_wantis empty would keep that cost to the people who chose a mic.@ -335,0 +426,4 @@/* (no changes allowed: SDL converts the device's sound to this. SDL2's id is a place in its list, so the caller takesit from a list made just before (host/nn_audio_in.c find_device). SDL2 opens a device by its name: of two with onename it takes the first, whichever was chosen; telling them apart is SDL3's) */const char *name = id ? ((const char *(*)(int, int))rec.DeviceName2)((int)id - 1, 1) : NULL;On SDL2 the id is a list index, and the first open after a menu change or stream start (
connect(src, 0)) takes it from the menu's cached list, which can be hours old. If the device order changed since (a headset connected, a webcam unplugged), indexid-1is now another device, or out of range and soNULL, which opens the default. Either way it opens as the wanted mic, gives sound, and is never retried. Opening by the namepickmatched, or always listing fresh inconnect, would avoid it.@ -0,0 +1,501 @@/* (hookshot) nn::audio's input streams: the microphone, as the ocarina's "play with your voice / an instrument" option uses it.The default only stands in when the wanted mic fails to open. A mic that still opens but stops sending (a stalled driver) goes quiet, then closes into silence, then reopens on retry and goes quiet again, and never gets the default the header comment promises. Opening the stand-in in the quiet branch (line ~251) when
mode == 2would match the "not plugged in" case.@ -0,0 +227,4 @@}src->mode = mode, src->want_id = want_id;snprintf(src->want, sizeof src->want, "%s", want);src->failures = 0, src->retry_wait = RETRY_NS;When a retry reopens the wanted mic under a new id from the fresh list,
src->want_idkeeps the old one, as it does when the source was created while the mic was missing (0). The next menu open then sends the new id,sameis false, and the working mic is closed and reopened: a short audio gap, a new log line, and the backoff reset. Updatingsrc->want_idto the idpickreturned whenever the wanted mic opens would avoid that.@ -0,0 +267,4 @@hos_log("audio in: %s is back", wanted(src));src->failures = 0, src->retry_wait = RETRY_NS;}} else if (now - src->heard_ns > QUIET_NS) { /* (pulled out, or its driver stopped) */Unplug detection still relies only on "no bytes for 2 s". If I remember SDL3 right, a removed recording device becomes a zombie that keeps delivering zeros, so on Windows
heardstays set, the stand-in never opens and there's no retry: the ocarina just gets silence. Opening the menu doesn't help either, becausechoose()treats index -1 ("not connected") with the same name as the same choice. HandlingSDL_EVENT_AUDIO_DEVICE_REMOVED, or checking now and then that the open device's id is still in a fresh list, would cover it. Treatingsel_index == -1as "missing" while that mic is open would help too.Good catch, thanks. Fixed in
e77bd79. The 60-byte cut now only applies to what’s saved in the ini. For a device that’s in the list, the menu sends the host its whole name. For one that isn’t (unplugged, so the menu only has the saved name), the host treats the saved name as the start of the device’s name. The test covers a 72-byte name found both ways.@ -0,0 +267,4 @@hos_log("audio in: %s is back", wanted(src));src->failures = 0, src->retry_wait = RETRY_NS;}} else if (now - src->heard_ns > QUIET_NS) { /* (pulled out, or its driver stopped) */Fixed in
e77bd79. While a chosen microphone is open, the host checks for it in a fresh device list every 5 s. When the menu shows it as “not connected”, the host checks straight away. If it’s gone, it’s closed, Default stands in, and the usual retries start. The test has a device that’s pulled out but keeps sending zeros.@ -335,0 +426,4 @@/* (no changes allowed: SDL converts the device's sound to this. SDL2's id is a place in its list, so the caller takesit from a list made just before (host/nn_audio_in.c find_device). SDL2 opens a device by its name: of two with onename it takes the first, whichever was chosen; telling them apart is SDL3's) */const char *name = id ? ((const char *(*)(int, int))rec.DeviceName2)((int)id - 1, 1) : NULL;Fixed in
e77bd79. The host no longer trusts a list position from earlier. Every open looks the device up by name in a list made just before opening, so an old position from the menu can’t open the wrong device.@ -0,0 +1,501 @@/* (hookshot) nn::audio's input streams: the microphone, as the ocarina's "play with your voice / an instrument" option uses it.Fixed in
e77bd79. A chosen microphone that stops sending is now handled like one that doesn’t open: Default stands in and it’s retried with the same backoff.@ -0,0 +227,4 @@}src->mode = mode, src->want_id = want_id;snprintf(src->want, sizeof src->want, "%s", want);src->failures = 0, src->retry_wait = RETRY_NS;Fixed in
e77bd79. When the microphone opens again under a new id, the source keeps the new id, so the menu’s next message about it counts as the same choice and doesn’t reopen it. There’s a test case for this.Fixed in
e77bd79. At startup the menu only lists microphones when one was chosen by name. With Off or Default it just tells the host the choice, so SDL audio isn’t started for this.Confirmed, thanks. The whole name goes to the host when the mic is listed, and
same_nametakes the cut one as a prefix when it isn't, so long names are found both ways.@ -0,0 +267,4 @@hos_log("audio in: %s is back", wanted(src));src->failures = 0, src->retry_wait = RETRY_NS;}} else if (now - src->heard_ns > QUIET_NS) { /* (pulled out, or its driver stopped) */Looks good. The 5 s check against a fresh list plus the immediate check when the menu shows it as not connected covers the zombie case. One small leftover: the check matches by name, so with two mics of the same name, pulling out the chosen one isn't noticed while the other is still listed. On SDL3 comparing the id with
want_idwould catch it, but that's an edge case and not blocking.@ -335,0 +426,4 @@/* (no changes allowed: SDL converts the device's sound to this. SDL2's id is a place in its list, so the caller takesit from a list made just before (host/nn_audio_in.c find_device). SDL2 opens a device by its name: of two with onename it takes the first, whichever was chosen; telling them apart is SDL3's) */const char *name = id ? ((const char *(*)(int, int))rec.DeviceName2)((int)id - 1, 1) : NULL;Confirmed,
find_devicealways lists fresh now, so a stale index from the menu can't open the wrong device.@ -0,0 +1,501 @@/* (hookshot) nn::audio's input streams: the microphone, as the ocarina's "play with your voice / an instrument" option uses it.Confirmed,
lost()puts the default in for a stalled mic the same way as one that doesn't open.@ -0,0 +227,4 @@}src->mode = mode, src->want_id = want_id;snprintf(src->want, sizeof src->want, "%s", want);src->failures = 0, src->retry_wait = RETRY_NS;Confirmed,
want_idfollows the id it was last opened under, and the test covers the menu's next message.Confirmed, thanks.
All the points from the last round are fixed in
e77bd79, and the merge doesn't touch the mic code. LGTM.Leftover nits, none blocking:
want_idwould catch it)Cretezy's question about controller mics is still unanswered above, might be worth a word.
It should, I got it working on my dualsense, but that's the only controller I have that has a microphone to test, in game reports the volume being low unless I'm right against it, though that could be down to the controller. As long as the device has a microphone that's registered, it should work.