218 lines
6.6 KiB
Text
218 lines
6.6 KiB
Text
-- The microphone and the voice pieces (PLAN.md section 62), with no microphone: SDL's disk audio
|
|
-- driver reads its "microphone" from a file, which this script writes first -- two seconds of
|
|
-- silence, three of a 220 Hz tone at half scale, three of 440 Hz at quarter scale, and silence after.
|
|
-- Run it headless from a scratch directory holding this folder:
|
|
--
|
|
-- SDL_AUDIO_DRIVER=disk SDL_AUDIO_DISK_INPUT_FILE=micIn.raw SDL_AUDIO_DISK_OUTPUT_FILE=micOut.raw \
|
|
-- SDL_AUDIO_FORMAT=F32 SDL_AUDIO_FREQUENCY=48000 SDL_AUDIO_CHANNELS=1 SDL_VIDEODRIVER=offscreen \
|
|
-- Singe -w micVoice/micVoice.singe
|
|
--
|
|
-- It prints one line: MICTEST pass=N fail=0. Every failure prints what it wanted first. What it
|
|
-- cannot prove here, and says so: that a real microphone sounds right, monitoring's delay, and a
|
|
-- calibration that succeeds -- nothing plays the disk driver's speaker into its microphone, so the
|
|
-- calibration must report that it never heard its clicks.
|
|
dofile("Singe/Framework.singe")
|
|
|
|
local RATE = 48000
|
|
local FRAME = 960
|
|
local INPUT = os.getenv("SDL_AUDIO_DISK_INPUT_FILE")
|
|
|
|
local pass = 0
|
|
local fail = 0
|
|
|
|
|
|
local function check(what, got, want)
|
|
if got == want then
|
|
pass = pass + 1
|
|
else
|
|
fail = fail + 1
|
|
debugPrint(string.format("FAIL %s: got %s, wanted %s", what, tostring(got), tostring(want)))
|
|
end
|
|
end
|
|
|
|
|
|
local function near(what, got, want, within)
|
|
check(what .. string.format(" (%.3f, wanted %.3f within %.3f)", got, want, within), math.abs(got - want) <= within, true)
|
|
end
|
|
|
|
|
|
-- 16-bit samples in a string, as micRead and opusDecode give them, and their RMS from 0 to 1.
|
|
local function rmsOf(samples)
|
|
local sum = 0
|
|
local count = #samples // 2
|
|
|
|
for i = 1, count do
|
|
local value = string.unpack("<i2", samples, i * 2 - 1)
|
|
|
|
sum = sum + value * value
|
|
end
|
|
return math.sqrt(sum / math.max(count, 1)) / 32767
|
|
end
|
|
|
|
|
|
-- The "microphone": float samples, one channel, as the disk driver will read them.
|
|
local function writeInput()
|
|
local parts = {}
|
|
|
|
local function tone(seconds, hz, level)
|
|
for i = 0, seconds * RATE - 1 do
|
|
parts[#parts + 1] = string.pack("<f", level * math.sin(2 * math.pi * hz * i / RATE))
|
|
end
|
|
end
|
|
tone(2, 0, 0)
|
|
tone(3, 220, 0.5)
|
|
tone(3, 440, 0.25)
|
|
tone(12, 0, 0)
|
|
local file = io.open(INPUT, "wb")
|
|
|
|
file:write(table.concat(parts))
|
|
file:close()
|
|
end
|
|
|
|
|
|
if not INPUT then
|
|
debugPrint("MICTEST needs SDL_AUDIO_DISK_INPUT_FILE; see the top of this script")
|
|
singeQuit()
|
|
return
|
|
end
|
|
writeInput()
|
|
|
|
check("a device is listed", #micList() >= 1, true)
|
|
check("micOpen", micOpen(), true)
|
|
check("micIsOpen", micIsOpen(), true)
|
|
|
|
local started = singeGetTicks()
|
|
local steps = {}
|
|
|
|
|
|
local function at(seconds, fn)
|
|
steps[#steps + 1] = { at = seconds * 1000, fn = fn, done = false }
|
|
end
|
|
|
|
|
|
-- Silence first.
|
|
at(1.0, function()
|
|
local hz = micPitch()
|
|
local rms = micLevel()
|
|
|
|
check("no pitch in silence", hz, 0)
|
|
check("silence is quiet", rms < 0.01, true)
|
|
end)
|
|
|
|
-- The 220 Hz stretch: a recording of two seconds, a read of one, and the pitch and level.
|
|
at(2.5, function()
|
|
check("micRecordStart", micRecordStart("micRecording.wav"), true)
|
|
micRead()
|
|
end)
|
|
at(3.5, function()
|
|
local samples = micRead()
|
|
local hz, clarity = micPitch()
|
|
local rms = micLevel()
|
|
|
|
near("one second read", #samples / 2, RATE, RATE * 0.1)
|
|
near("the read's level", rmsOf(samples), 0.5 / math.sqrt(2), 0.03)
|
|
near("220 Hz found", hz, 220, 1.0)
|
|
check("220 Hz is clear", clarity > 0.9, true)
|
|
near("220 Hz level", rms, 0.5 / math.sqrt(2), 0.03)
|
|
|
|
-- Opus round trip on a frame of it, and the decoded frame's level against the original's.
|
|
local frame = samples:sub(1, FRAME * 2)
|
|
local encoder = opusEncoderNew(RATE, 1, "audio")
|
|
local decoder = opusDecoderNew(RATE, 1)
|
|
local decoded = nil
|
|
|
|
opusEncoderSet(encoder, 64000, 10)
|
|
for _ = 1, 5 do
|
|
decoded = opusDecode(decoder, opusEncode(encoder, frame))
|
|
end
|
|
check("a decoded frame is a frame", #decoded, FRAME * 2)
|
|
near("a decoded frame keeps its level", rmsOf(decoded), rmsOf(frame), rmsOf(frame) * 0.25)
|
|
check("a lost packet is concealed to a frame", #opusDecode(decoder, nil), FRAME * 2)
|
|
voiceFree(encoder)
|
|
voiceFree(decoder)
|
|
|
|
-- The voice processor on a run of frames of it.
|
|
local processor = voiceProcessorNew(FRAME, RATE, 100)
|
|
local cleaned = nil
|
|
local voiced = nil
|
|
|
|
for i = 0, 40 do
|
|
local piece = samples:sub((i % 40) * FRAME * 2 + 1, (i % 40 + 1) * FRAME * 2)
|
|
|
|
cleaned, voiced = voiceProcess(processor, piece)
|
|
end
|
|
check("a processed frame is a frame", #cleaned, FRAME * 2)
|
|
check("a processed tone is not silenced", rmsOf(cleaned) > 0, true)
|
|
check("voiced is a boolean", type(voiced), "boolean")
|
|
voiceFree(processor)
|
|
end)
|
|
at(4.5, function()
|
|
micRecordStop()
|
|
local file = io.open(singeGetDataPath() .. "files/micRecording.wav", "rb") or io.open("micRecording.wav", "rb")
|
|
local data = file and file:read("a") or ""
|
|
|
|
if file then
|
|
file:close()
|
|
end
|
|
check("the recording is a WAV", data:sub(1, 4) .. data:sub(9, 12), "RIFFWAVE")
|
|
check("the recording is 48 kHz", #data >= 28 and string.unpack("<I4", data, 25) or 0, RATE)
|
|
near("the recording is two seconds", (#data - 44) / 2, RATE * 2, RATE * 0.1)
|
|
end)
|
|
|
|
-- The 440 Hz stretch.
|
|
at(6.5, function()
|
|
local hz = micPitch()
|
|
local rms = micLevel()
|
|
|
|
near("440 Hz found", hz, 440, 2.0)
|
|
near("440 Hz level", rms, 0.25 / math.sqrt(2), 0.02)
|
|
check("monitoring on", micMonitor(true, 0.5), true)
|
|
check("monitoring off", micMonitor(false), true)
|
|
end)
|
|
|
|
-- The jitter buffer: three packets put in out of order come out in order.
|
|
at(7.0, function()
|
|
local jitter = jitterNew(FRAME)
|
|
local order = {}
|
|
|
|
jitterPut(jitter, "third", FRAME * 2)
|
|
jitterPut(jitter, "first", 0)
|
|
jitterPut(jitter, "second", FRAME)
|
|
for _ = 1, 8 do
|
|
local packet, status = jitterGet(jitter)
|
|
|
|
if packet then
|
|
order[#order + 1] = packet
|
|
end
|
|
check("a jitter status is one of its three", status == "ok" or status == "missing" or status == "insertion", true)
|
|
end
|
|
check("the jitter buffer puts packets back in order", table.concat(order, ","), "first,second,third")
|
|
voiceFree(jitter)
|
|
end)
|
|
|
|
-- Silence again, then a calibration nobody can hear.
|
|
at(9.0, function()
|
|
check("no pitch after the tones", micPitch(), 0)
|
|
check("micCalibrate starts", micCalibrate(), true)
|
|
check("it is running", (micCalibration()), "running")
|
|
end)
|
|
at(17.0, function()
|
|
check("a calibration never heard says so", (micCalibration()), "failed")
|
|
micClose()
|
|
check("micClose", micIsOpen(), false)
|
|
debugPrint(string.format("MICTEST pass=%d fail=%d", pass, fail))
|
|
singeQuit()
|
|
end)
|
|
|
|
|
|
function onOverlayUpdate()
|
|
local now = singeGetTicks() - started
|
|
|
|
for _, step in ipairs(steps) do
|
|
if not step.done and now >= step.at then
|
|
step.done = true
|
|
step.fn()
|
|
end
|
|
end
|
|
return OVERLAY_UPDATED
|
|
end
|