cc-mek-scada/scada-common/audio.lua
2023-08-26 19:01:22 -04:00

317 lines
8.5 KiB
Lua

--
-- Audio & Tone Control for Alarms
--
-- sounds modeled after https://www.e2s.com/references-and-guidelines/listen-and-download-alarm-tones
-- note: max samples = 0x20000 (128 * 1024 samples)
local _2_PI = 2 * math.pi -- 2 whole pies, hope you're hungry
local _DRATE = 48000 -- 48kHz audio
local _MAX_VAL = 127 / 2 -- max signed integer in this 8-bit audio
local _05s_SAMPLES = 24000 -- half a second worth of samples
---@class audio
local audio = {}
---@enum TONE
local TONE = {
T_340Hz_Int_2Hz = 1,
T_544Hz_440Hz_Alt = 2,
T_660Hz_Int_125ms = 3,
T_745Hz_Int_1Hz = 4,
T_800Hz_Int = 5,
T_800Hz_1000Hz_Alt = 6,
T_1000Hz_Int = 7,
T_1800Hz_Int_4Hz = 8
}
audio.TONE = TONE
local tone_data = {
{ {}, {}, {}, {} }, -- 340Hz @ 2Hz Intermittent
{ {}, {}, {}, {} }, -- 544Hz 100mS / 440Hz 400mS Alternating
{ {}, {}, {}, {} }, -- 660Hz @ 125ms On 125ms Off
{ {}, {}, {}, {} }, -- 745Hz @ 1Hz Intermittent
{ {}, {}, {}, {} }, -- 800Hz @ 0.25s On 1.75s Off
{ {}, {}, {}, {} }, -- 800/1000Hz @ 0.25s Alternating
{ {}, {}, {}, {} }, -- 1KHz 1s on, 1s off Intermittent
{ {}, {}, {}, {} } -- 1.8KHz @ 4Hz Intermittent
}
-- calculate how many samples are in the given number of milliseconds
---@nodiscard
---@param ms integer milliseconds
---@return integer samples
local function ms_to_samples(ms) return math.floor(ms * 48) end
--#region Tone Generation (the Maths)
-- 340Hz @ 2Hz Intermittent
local function gen_tone_1()
local t, dt = 0, _2_PI * 340 / _DRATE
for i = 1, _05s_SAMPLES do
local val = math.floor(math.sin(t) * _MAX_VAL)
tone_data[1][1][i] = val
tone_data[1][3][i] = val
tone_data[1][2][i] = 0
tone_data[1][4][i] = 0
t = (t + dt) % _2_PI
end
end
-- 544Hz 100mS / 440Hz 400mS Alternating
local function gen_tone_2()
local t1, dt1 = 0, _2_PI * 544 / _DRATE
local t2, dt2 = 0, _2_PI * 440 / _DRATE
local alternate_at = ms_to_samples(100)
for i = 1, _05s_SAMPLES do
local value
if i <= alternate_at then
value = math.floor(math.sin(t1) * _MAX_VAL)
t1 = (t1 + dt1) % _2_PI
else
value = math.floor(math.sin(t2) * _MAX_VAL)
t2 = (t2 + dt2) % _2_PI
end
tone_data[2][1][i] = value
tone_data[2][2][i] = value
tone_data[2][3][i] = value
tone_data[2][4][i] = value
end
end
-- 660Hz @ 125ms On 125ms Off
local function gen_tone_3()
local elapsed_samples = 0
local alternate_after = ms_to_samples(125)
local alternate_at = alternate_after
local mode = true
local t, dt = 0, _2_PI * 660 / _DRATE
for set = 1, 4 do
for i = 1, _05s_SAMPLES do
if mode then
local val = math.floor(math.sin(t) * _MAX_VAL)
tone_data[3][set][i] = val
t = (t + dt) % _2_PI
else
t = 0
tone_data[3][set][i] = 0
end
if elapsed_samples == alternate_at then
mode = not mode
alternate_at = elapsed_samples + alternate_after
end
elapsed_samples = elapsed_samples + 1
end
end
end
-- 745Hz @ 1Hz Intermittent
local function gen_tone_4()
local t, dt = 0, _2_PI * 745 / _DRATE
for i = 1, _05s_SAMPLES do
local val = math.floor(math.sin(t) * _MAX_VAL)
tone_data[4][1][i] = val
tone_data[4][3][i] = val
tone_data[4][2][i] = 0
tone_data[4][4][i] = 0
t = (t + dt) % _2_PI
end
end
-- 800Hz @ 0.25s On 1.75s Off
local function gen_tone_5()
local t, dt = 0, _2_PI * 800 / _DRATE
local stop_at = ms_to_samples(250)
for i = 1, _05s_SAMPLES do
local val = math.floor(math.sin(t) * _MAX_VAL)
if i > stop_at then
tone_data[5][1][i] = val
else
tone_data[5][1][i] = 0
end
tone_data[5][2][i] = 0
tone_data[5][3][i] = 0
tone_data[5][4][i] = 0
t = (t + dt) % _2_PI
end
end
-- 1000/800Hz @ 0.25s Alternating
local function gen_tone_6()
local t1, dt1 = 0, _2_PI * 1000 / _DRATE
local t2, dt2 = 0, _2_PI * 800 / _DRATE
local alternate_at = ms_to_samples(250)
for i = 1, _05s_SAMPLES do
local val
if i <= alternate_at then
val = math.floor(math.sin(t1) * _MAX_VAL)
t1 = (t1 + dt1) % _2_PI
else
val = math.floor(math.sin(t2) * _MAX_VAL)
t2 = (t2 + dt2) % _2_PI
end
tone_data[6][1][i] = val
tone_data[6][2][i] = val
tone_data[6][3][i] = val
tone_data[6][4][i] = val
end
end
-- 1KHz 1s on, 1s off Intermittent
local function gen_tone_7()
local t, dt = 0, _2_PI * 1000 / _DRATE
for i = 1, _05s_SAMPLES do
local val = math.floor(math.sin(t) * _MAX_VAL)
tone_data[7][1][i] = val
tone_data[7][2][i] = val
tone_data[7][3][i] = 0
tone_data[7][4][i] = 0
t = (t + dt) % _2_PI
end
end
-- 1800Hz @ 4Hz Intermittent
local function gen_tone_8()
local t, dt = 0, _2_PI * 1800 / _DRATE
local off_at = ms_to_samples(250)
for i = 1, _05s_SAMPLES do
local val = 0
if i <= off_at then
val = math.floor(math.sin(t) * _MAX_VAL)
t = (t + dt) % _2_PI
end
tone_data[8][1][i] = val
tone_data[8][2][i] = val
tone_data[8][3][i] = val
tone_data[8][4][i] = val
end
end
--#endregion
-- generate all 8 tone sequences
function audio.generate_tones()
gen_tone_1(); gen_tone_2(); gen_tone_3(); gen_tone_4(); gen_tone_5(); gen_tone_6(); gen_tone_7(); gen_tone_8()
end
-- hard audio limiter
---@nodiscard
---@param output number output level
---@return number limited -128.0 to 127.0
local function limit(output)
return math.max(-128, math.min(127, output))
end
-- clear output buffer
---@param buffer table quad buffer
local function clear(buffer)
for i = 1, 4 do
for s = 1, _05s_SAMPLES do buffer[i][s] = 0 end
end
end
-- create a new audio tone stream controller
function audio.new_stream()
local self = {
any_active = false,
need_recompute = false,
next_block = 1,
-- split audio up into 0.5s samples, so specific components can be ended quicker
quad_buffer = { {}, {}, {}, {} },
-- all tone enable states
tone_active = { false, false, false, false, false, false, false, false }
}
clear(self.quad_buffer)
---@class tone_stream
local public = {}
-- add a tone to the output buffer
---@param index TONE tone ID
---@param active boolean active state
function public.set_active(index, active)
if self.tone_active[index] ~= nil then
if self.tone_active[index] ~= active then self.need_recompute = true end
self.tone_active[index] = active
end
end
-- check if a tone is active
---@param index TONE tone index
function public.is_active(index)
if self.tone_active[index] then return self.tone_active[index] end
return false
end
-- set all tones inactive, reset next block, and clear output buffer
function public.stop()
for i = 1, #self.tone_active do self.tone_active[i] = false end
self.next_block = 1
clear(self.quad_buffer)
end
-- check if the output buffer needs to be recomputed due to changes
function public.is_recompute_needed() return self.need_recompute end
-- re-compute the output buffer
function public.compute_buffer()
clear(self.quad_buffer)
self.need_recompute = false
self.any_active = false
for id = 1, #tone_data do
if self.tone_active[id] then
self.any_active = true
for i = 1, 4 do
local buffer = self.quad_buffer[i]
local values = tone_data[id][i]
for s = 1, _05s_SAMPLES do self.quad_buffer[i][s] = limit(buffer[s] + values[s]) end
end
end
end
end
-- check if any alarms are active
function public.any_active() return self.any_active end
-- check if the next audio block has data
function public.has_next_block() return #self.quad_buffer[self.next_block] > 0 end
-- get the next audio block
function public.get_next_block()
local block = self.quad_buffer[self.next_block]
self.next_block = self.next_block + 1
if self.next_block > 4 then self.next_block = 1 end
return block
end
return public
end
return audio