This commit is contained in:
Kimapr 2024-05-27 15:06:41 +05:00
commit 937d542281
8 changed files with 499 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/sound.lua

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
Copyright (C)2024 Kimapr
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject
to the following conditions:
The above copyright notice and this permission notice shall be included
in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

10
README.md Normal file
View file

@ -0,0 +1,10 @@
# Sound synthesis playground
Experiment with sound generation in Lua.
# Usage
- Copy `sound.lua.example` to `sound.lua`
- Open `sound.lua` in your text editor (autosave on change recommended)
- Run this with `love .`
- Edit `sound.lua`, and watch (listen) the sound update in real time

5
conf.lua Normal file
View file

@ -0,0 +1,5 @@
function love.conf(t)
t.identity="net.kimapr.sgstream"
t.window.title = "sgstream"
t.window.resizable = true
end

100
main.lua Normal file
View file

@ -0,0 +1,100 @@
local rate,bits = 44100,16
local chunk = 1/4
local look = 1/4
local chunkc = 2
local sour,pos
local emsnd = require("soundgen")
.silence(0)
:clamp(0,chunk)
:compile(rate,bits)
local chunkd = {}
local function samplify(snd)
local chunk = {}
for i=0,snd:getSampleCount()-1 do
chunk[i] = snd:getSample(i,1)
end
chunkd[#chunkd+1] = chunk
return snd
end
local sndc = love.thread.getChannel("snd")
local ctrl = love.thread.getChannel("ctrl")
local function push(c,v)
ctrl:supply(c)
ctrl:push(v)
end
local offset = 0
local function init()
sour = love.audio.newQueueableSource(rate,bits,1,chunkc)
pos = 0
chunkd={}
for n=1,chunkc*2 do
samplify(emsnd)
end
push("chunk",chunk)
push("emsnd",emsnd)
push("pos",pos)
push("look",look)
push("rate",rate)
push("bits",bits)
while offset>0 do
local c = sndc:demand()
local v = sndc:demand()
if c == "snd" then
offset=offset-1
end
end
end
function love.load()
love.thread.newThread("thread.lua"):start()
init()
sour:play()
end
function love.keypressed(key)
if key == "r" then
sour:pause()
sour:stop()
init()
end
end
function love.update(dt)
for n=1,sour:getFreeBufferCount()-offset do
push("snd",true)
offset=offset+1
table.remove(chunkd,1)
end
while sndc:getCount()>0 do
local c = sndc:demand()
local v = sndc:demand()
if c == "snd" then
sour:queue(samplify(v))
sour:play()
offset=offset-1
elseif c == "pos" then
pos = v
elseif c == "look" then
look = v
else
error("unkc: "..tostring(c))
end
end
end
function love.draw()
local w,h = love.graphics.getDimensions()
local line = {}
local sx = math.floor(((chunkc+sour:getFreeBufferCount()-offset)*chunk+sour:tell())*rate)
local rr = emsnd:getSampleCount()
for t=0,w do
local x = t/w
x=x*2-1
x=x*rate*look
x=sx+x
line[#line+1]=t
x=math.floor(x+.5)
local si,ii = math.floor(x/rr)+1,x%rr
line[#line+1]=(((chunkd[si]or{})[ii]or 0)*0.5+0.5)*h
end
love.graphics.setColor(1,1,1,0.1)
love.graphics.line(w/2,0,w/2,h)
love.graphics.setColor(1,1,1)
love.graphics.line(line)
end

38
sound.lua.example Normal file
View file

@ -0,0 +1,38 @@
local sg = require "soundgen"
sg=(function(sg)
local obj = setmetatable({},sg)
obj.__index = obj
obj.proto = obj
-- custom methods here
return obj
end)(sg)
-- throw a bunch of random things together
local snd = sg:sine():freq(250)
:pm(sg:sine():freq(250/6):fade(0,0.3):amp(1/250),-1,1)
snd=snd:amp(0.8):freq(2):add(
sg:sine():freq(250)
:pm(sg:sine():freq(250),0,1/250):amp(0.3))
snd=snd:freq(1)
:pm(snd:phase(0):fade(0.3,0.0),0,0.001)
:freq(0.5)
-- fade in, fade out, reverb
snd=snd
:fade(0.01,0)
:fade(0,0.3)
:amp(0.5)
:reverb(4,1/250*30,0.4)
-- compose
local ssnd = sg.add(
snd:freq(1):phase(0),
snd:freq(0.99):phase(-0.5),
snd:freq(1.01):phase(-1),
snd:freq(1):phase(-1.4),
snd:freq(1.1):phase(-1.8)
):clamp(0,2.1,true):loop():amp(1)
return
ssnd, -- sound
1/250*6 -- oscillogram lookaround

259
soundgen.lua Normal file
View file

@ -0,0 +1,259 @@
--[[--
Copyright (C)2023-2024 Kimapr
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject
to the following conditions:
The above copyright notice and this permission notice shall be included
in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--]]--
local smt={}
smt.__index=smt
smt.proto=smt
local function round(x)
return math.floor(x+.5)
end
function smt:bounds_and(a,b,c,...)
if not a or not b then return false end
local mi,ma
mi=math.max(a[1],b[1])
ma=math.min(a[2],b[2])
if ma-mi<=0 then return false end
if c then
return self:bounds_and({mi,ma},c,...)
end
return {mi,ma}
end
function smt:bounds_or(a,b,c,...)
local mi,ma=math.min(a[1],b[1]),math.max(a[2],b[2])
if not a then return b end
if not b then return a end
if c then
return self:bounds_or({mi,ma},c,...)
end
return {mi,ma}
end
function smt:new(fn,bounds)
local obj={}
obj.bounds=bounds
if obj.bounds==nil then
obj.bounds={-math.huge,math.huge}
end
obj.sample=fn
return setmetatable(obj,self.proto)
end
function smt:sine()
return self:new(function(self,t)
return math.sin(t*math.pi*2)
end)
end
function smt:square()
return self:new(function(self,t)
return (t%1)<=0.5 and -1 or 1
end)
end
function smt:sawtooth()
return self:new(function(self,t)
return (t%1)*2-1
end)
end
function smt:triangle()
return self:new(function(self,t)
--return ((t%1)<=0.5 and (t*2)%1 or 1-(t*2)%1)*2-1 -- sounds cool and glitchy but wrong
local a=t%1
if a>0.5 then
return (1-(a*2-1))*2-1
end
return (a*2)*2-1
end)
end
function smt:noise(y,z,x)
return self:new(function(self,t)
return love.math.noise(t,y,z,x)*2-1
end)
end
function smt:silence(a)
a=a or 0
return smt:new(function()return a end)
end
local function rated_bounds(bounds,rate)
local mi,ma=bounds[1],bounds[2]
mi,ma=mi*rate,ma*rate
mi,ma=round(mi),round(ma)-1
return mi,ma
end
local function prefi(y)
return math.min(1,math.max(-1,y))
end
function smt:compile(rate,bits,alt)
rate=rate or 44100
bits=bits or 16
local bounds=self.bounds
local mi,ma=rated_bounds(bounds,rate)
if alt then
bounds=self:bounds_and(bounds,alt.bounds)
local mi,ma=rated_bounds(bounds,rate)
local sdata=love.sound.newSoundData(ma-mi+1,rate,bits,2)
for x=mi,ma do
local x=x+mi
sdata:setSample(x*2,prefi(self:sample(x/rate+0.5/rate)))
sdata:setSample(x*2+1,prefi(alt:sample(x/rate+0.5/rate)))
end
return sdata
end
local sdata=love.sound.newSoundData(ma-mi+1,rate,bits,1)
for x=mi,ma do
local xx=x-mi
sdata:setSample(xx,prefi(self:sample(x/rate+0.5/rate)))
end
return sdata
end
function smt:phase(p)
local bb=self.bounds
if bb then
bb={bb[1]-p,bb[2]-p}
end
return self:new(function(obj,t)
return self:sample(t+p)
end,bb)
end
function smt:amp(a)
return self:new(function(obj,t)
return self:sample(t)*a
end,self.bounds)
end
function smt:freq(f)
local bb=self.bounds
if bb then
bb={bb[1]/f,bb[2]/f}
if bb[2]<bb[1] then
bb={bb[2],bb[1]}
end
end
return self:new(function(obj,t)
return self:sample(t*f)
end,bb)
end
function smt:clamp(mi,ma,extend)
local bounds = self:bounds_and(self.bounds,{mi,ma})
return self:new(function(nself,t)
if t<mi or t>ma then return 0 end
return self.sample(self,t)
end,extend and {mi,ma} or bounds or (mi<self.bounds[1]
and {self.bounds[1],self.bounds[1]}
or {self.bounds[2],self.bounds[2]}))
end
function smt:loop()
if self.bounds[2]-self.bounds[1] == math.huge then
return self
end
local mi,ma = self.bounds[1],self.bounds[2]
return smt:new(function(_,t)
return self:sample((t-mi)%ma+mi)
end)
end
function smt.am(car,mod,mi,ma)
return car:new(function(self,t)
return car:sample(t)*((mod:sample(t)*0.5+0.5)*(ma-mi)+mi)
end,car:bounds_and(car.bounds,mod.bounds))
end
function smt.pm(car,mod,mi,ma)
assert(car.bounds
and car.bounds[1]==-math.huge
and car.bounds[2]==math.huge,
"infinite carrier expected")
mod=mod:add(mod:silence())
return car:new(function(self,t)
return car:sample(t+((mod:sample(t)*0.5+0.5)*(ma-mi)+mi))
end,car.bounds)
end
function smt:add(a,b,...)
if self.sample then
return self.proto:add(self,a,b,...)
end
if not b then
if not a then return self:silence() end
return a
end
local ls={a,b,...}
local bbs={}
for k,v in ipairs(ls) do
bbs[k]=v.bounds
end
return self:new(function(self,t)
local r=0
for k=1,#ls do
local v = ls[k]
r=r+v:sample(t)
end
return r
end,a:bounds_or(unpack(bbs)))
end
do
local fwav=smt:sine():phase(0.75):freq(0.25)
fwav=fwav:clamp(0,1)
fwav=fwav:add(smt:silence(-1):clamp(-math.huge,0))
function smt.fade(wave,ma,mi)
local ff=fwav:freq(1/(mi-ma)):phase(-ma)
return wave:am(ff,1,-1)
end
end
function smt:reverb(times,dist,mul)
return self:new(function(_,t)
local s = 0
for n=0,times-1 do
local tt = t-n*dist
if tt>=self.bounds[1] and tt<=self.bounds[2] then
s = s + self:sample(t-n*dist)*(mul^n)
end
end
return s
end,{self.bounds[1],self.bounds[2]+times*dist})
end
function smt.fm(car,mod,rate,mi,ma) -- TODO
local cframe,cache=0,{}
return smt.new(function(self,t)
end,mod.bounds)
end
return smt

65
thread.lua Normal file
View file

@ -0,0 +1,65 @@
require "love.sound"
require "love.thread"
require "love.math"
require "love.timer"
local ctrl = love.thread.getChannel("ctrl")
local sndc = love.thread.getChannel("snd")
local pos,look,chunk,rate,bits
local emsnd
local function send(c,v)
sndc:push(c)
sndc:push(v)
end
local function go()
local t = love.timer.getTime()
local ok,err,x = xpcall(function()
local snd,x = dofile("sound.lua")
snd = snd:phase(pos):clamp(0,chunk,true)
snd = assert(snd:compile(rate,bits),"no sound")
assert(snd:typeOf("SoundData"),"fake sound")
return snd,x
end,debug.traceback)
look = tonumber(x) or look
sndc:performAtomic(function()
send("look",look)
if not ok then
send("snd",emsnd)
print(err)
else
send("snd",err)
end
pos=pos+chunk
send("pos",pos)
end)
t=love.timer.getTime()-t
local chnkcc=16
local pgb=math.floor(t/chunk*chnkcc+.5)
print(("time: %.2f..%.2f\tload: %.2f%%\t[%s%s%s]"):format(
pos-chunk,pos,
math.floor(t/chunk*100+.5),
("#"):rep(math.min(pgb,chnkcc)),
("."):rep(math.max(0,chnkcc-pgb)),
("!"):rep(math.max(0,-(chnkcc-pgb)))
))
end
while true do
local cmd = ctrl:demand()
local v = ctrl:demand()
if cmd == "pos" then
pos = v
elseif cmd == "look" then
look = v
elseif cmd == "chunk" then
chunk = v
elseif cmd == "emsnd" then
emsnd = v
elseif cmd == "rate" then
rate = v
elseif cmd == "bits" then
bits = v
elseif cmd == "snd" then
go()
else
error("unkc: "..tostring(cmd))
end
end