singe/testScripts/micVoice/micVoice.singe

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