#!/usr/bin/env bash
#
# Holds the game back until the virtual microphone is the shape it will keep.
#
# The ALSA loopback is one cable with two ends: archstar-web writes the phones'
# mix into hw:<card>,0 and the game records it from hw:<card>,1. The kernel
# gives that cable **one** format, taken from whichever end opens first, and
# refuses the second end anything else (`rule_rate`, `rule_channels`,
# `rule_format` in sound/drivers/aloop.c: while one side is set up, the other
# is constrained to exactly its rate, channels and format).
#
# A free cable advertises what the driver can do rather than what is on it:
# 1 to 32 channels, 8 kHz to 768 kHz. PortAudio reports that to the game as the
# device's capabilities, and UltraStar opens a microphone with the channel
# count and sample rate it was told (`channelCnt := maxInputChannels` in
# UAudioInput_Portaudio.EnumDevices) - so a game that looked at the cable
# before anybody wrote to it decided on 32 channels, remembered that for the
# whole session, and every later open failed against a cable the writer had
# meanwhile pinned at 48 kHz stereo. Nothing crashed. The phones streamed, the
# page said the microphone was live, the vu-meter moved, and the game heard
# silence - for the whole evening, because the decision is taken once at
# startup.
#
# So the order is not a detail, it is the whole of it: the writer opens the
# cable, the cable takes 48 kHz stereo, and only then may the game look at it
# and see a plain stereo microphone. This waits for exactly that.
#
# It never keeps the game from starting. A machine whose microphone never
# comes up is a machine that still plays music and still shows the words - the
# timeout is generous, and what it saw goes in the journal so the reason is
# there to read rather than to guess at.
#
# Usage: archstar-wait-for-mic
#
# Environment (the kiosk sets none of them; the suite sets all of them):
#   ARCHSTAR_ASOUND             where /proc/asound is
#   ARCHSTAR_MIC_CARD_INDEX     the loopback's card index (modprobe.d/snd-aloop.conf)
#   ARCHSTAR_MIC_CARD_ID        the loopback's card id, for the messages
#   ARCHSTAR_MIC_RATE           the rate to wait for
#   ARCHSTAR_MIC_CHANNELS       the channel count to wait for
#   ARCHSTAR_MIC_WAIT_SECONDS   how long to wait before giving up
#   ARCHSTAR_MIC_POLL_SECONDS   how often to look

set -Eeuo pipefail

ASOUND="${ARCHSTAR_ASOUND:-/proc/asound}"
# The index is the address; the id is only ever printed. `struct snd_card`
# declares `char id[16]`, so a card id past fifteen characters is truncated by
# the kernel without a word - and this script used to look for
# /proc/asound/ArchStarVirtualMic, eighteen characters, a directory that could
# never exist. It found nothing, said "no loopback at all", and returned
# instantly on every boot it ever ran on: the ordering it was written to
# guarantee was never once enforced. Both ends of the cable are addressed by
# index (hw:<index>,0 and hw:<index>,1), so the index is the only identifier
# that cannot disagree with what is actually open.
CARD_INDEX="${ARCHSTAR_MIC_CARD_INDEX:-10}"
CARD_ID="${ARCHSTAR_MIC_CARD_ID:-ArchStarMic}"
WANT_RATE="${ARCHSTAR_MIC_RATE:-48000}"
WANT_CHANNELS="${ARCHSTAR_MIC_CHANNELS:-2}"
WAIT_SECONDS="${ARCHSTAR_MIC_WAIT_SECONDS:-30}"
POLL_SECONDS="${ARCHSTAR_MIC_POLL_SECONDS:-0.25}"

say() { printf '[archstar-mic] %s\n' "$*"; }

# Where the kernel describes our card. By index first, because that is what
# the writer opens; by name second, so that a machine whose id did survive -
# and every fake tree the suite builds - is read the same way.
card_dir() {
    if [[ -e "${ASOUND}/card${CARD_INDEX}" ]]; then
        printf '%s/card%s' "${ASOUND}" "${CARD_INDEX}"
    else
        printf '%s/%s' "${ASOUND}" "${CARD_ID}"
    fi
}

# The playback end of the cable - the one archstar-web writes into. Its
# hw_params reads `closed` until somebody opens it, and then names the format
# the whole cable now has.
params_path() {
    printf '%s/pcm0p/sub0/hw_params' "$(card_dir)"
}

# The rate and channel count on the cable right now, as "<rate> <channels>",
# or nothing at all when no end is open. `rate:` carries a fraction after the
# number - `rate: 48000 (48000/1)` - and only the first field is the rate.
cable_now() {
    local path rate channels
    path="$(params_path)"
    [[ -r "${path}" ]] || return 1
    rate="$(sed -n 's/^rate: \([0-9]*\).*/\1/p' "${path}" 2>/dev/null | head -1)"
    channels="$(sed -n 's/^channels: \([0-9]*\).*/\1/p' "${path}" 2>/dev/null | head -1)"
    [[ -n "${rate}" && -n "${channels}" ]] || return 1
    printf '%s %s\n' "${rate}" "${channels}"
}

main() {
    local deadline now seen=''

    if [[ ! -e "$(card_dir)" ]]; then
        # Said rather than waited out: no loopback at all is a different fault
        # from a loopback nobody has written to, and thirty seconds of silence
        # would describe neither.
        say "no $(card_dir): the snd-aloop module did not come up, so there is no virtual microphone to wait for"
        say "the game starts anyway; the phones will stream into nothing until it does"
        return 0
    fi

    deadline="$(( $(date +%s) + WAIT_SECONDS ))"
    while :; do
        if seen="$(cable_now)"; then
            if [[ "${seen}" == "${WANT_RATE} ${WANT_CHANNELS}" ]]; then
                say "the virtual microphone is open at ${WANT_RATE} Hz, ${WANT_CHANNELS} channels; the game may look at it now"
                return 0
            fi
            # Somebody opened it in a shape neither end can sing through. Worth
            # a line of its own: waiting longer will not change it.
            say "the loopback is open at ${seen// / Hz, } channels, not ${WANT_RATE} Hz ${WANT_CHANNELS}; the game and the writer will not agree"
            return 0
        fi
        now="$(date +%s)"
        if (( now >= deadline )); then
            say "the virtual microphone was not open after ${WAIT_SECONDS}s: archstar-web never wrote to card ${CARD_INDEX} (${CARD_ID})"
            say "the game starts anyway, and will decide about that device on its own"
            return 0
        fi
        sleep "${POLL_SECONDS}"
    done
}

main "$@"
