~nasser/8fl

8fl/main.lua -rw-r--r-- 9.1 KiB
2934f9e8Ramsey Nasser Use negative track numbers for fx columns a month ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
-- 8FL -- The Eighth Floor
-- Fennel Livecoding for Renoise

-- This file sets up the REPL, session management bookkeeping, and other bits of
-- supporting infrastructure. Most of 8FL is implemented in Fennel.

local _8FL_VERSION = "0.4"
local fennel = require("fennel")
local fv = require("fennelview")
table.insert(package.loaders or package.searchers, fennel.makeSearcher({useMetadata=true}))

-- Reload a library in place. Useful for development.
-- Taken from https://technomancy.us/189
function reload(lib)
    local old = package.loaded[lib] or {} -- require(lib)
    package.loaded[lib] = nil
    local new = require(lib)
    if type(new) == "table" then
        for k, v in pairs(new) do
            old[k] = v
        end
        for k, _ in pairs(old) do
            if new[k] == nil then
                old[k] = nil
            end
        end
        package.loaded[lib] = old
        return old
    end
end

-- Send the 8FL banner to a listening socket. Displays the logo + version
-- information. Expects banner.txt to exist in the same folder as this script.
function banner(socket)
    for line in io.lines("banner.txt") do
        socket:send(line .. "\n")
    end
    socket:send("\n8FL: ")
    socket:send(_8FL_VERSION)
    socket:send("\nfennel: ")
    socket:send(fennel.version)
    socket:send("\nlua: ")
    socket:send(_VERSION)
    socket:send("\nrenoise: ")
    socket:send(renoise.RENOISE_VERSION)
    socket:send("\n\n")
end

-- Turn an object into a string. Uses fennelview when possible, falling back to
-- tostring if it needs to.
function stringify(obj)
    local ok, string = pcall(fv, obj)
    if ok then
        return string
    end

    local ok, string = pcall(tostring, obj)
    if ok then
        return string
    end

    return "#<" .. tostring(type(obj)) .. ">"
end

-- Is c an open bracket? Used to balance multiline REPL input.
function openBracket(c)
    return c == 40 or c == 91 or c == 123
end

-- Is c a close bracket? Used to balance multiline REPL input.
function closeBracket(c)
    return c == 41 or c == 93 or c == 125
end

-- Is the string s a balanced lisp expression? Used to decide whether to wait
-- for more REPL input.
function balanced(s)
    local i = 0
    for c in fennel.stringStream(s) do
        if openBracket(c) then
            i = i + 1
        elseif closeBracket(c) then
            i = i - 1
        end
    end
    return i == 0
end

-- Saves locals introduced in REPL expressions so that they're visible to later
-- REPL expressions, which does not happen by default. Taken from fennel.repl
local save_source = table.concat({"local ___i___ = 1", "while true do", " local name, value = debug.getlocal(1, ___i___)", " if(name and name ~= \"___i___\") then", " ___replLocals___[name] = value", " ___i___ = ___i___ + 1", " else break end end"}, "\n")
local function splice_save_locals(env, lua_source)
  env.___replLocals___ = (env.___replLocals___ or {})
  local spliced_source = {}
  local bind = "local %s = ___replLocals___['%s']"
  for line in lua_source:gmatch("([^\n]+)\n?") do
    table.insert(spliced_source, line)
  end
  for name in pairs(env.___replLocals___) do
    table.insert(spliced_source, 1, bind:format(name, name))
  end
  if ((1 < #spliced_source) and (spliced_source[#spliced_source]):match("^ *return .*$")) then
    table.insert(spliced_source, #spliced_source, save_source)
  end
  return table.concat(spliced_source, "\n")
end

-- Evaluate a chunk of Fennel code in a given environment and scope
function evalChunk(buffer, env, scope)
    local ok, luaCode = pcall(fennel.compileString, buffer, {scope=scope, useMetadata=true})
    if not ok then
        error("failed to compile " .. luaCode)
    end
    local luaCodeSpliced = splice_save_locals(env, luaCode)
    local f, err = loadstring(luaCodeSpliced)
    if err then
        error("failed to load " .. luaCodeSpliced .. " error: " .. err)
    end
    setfenv(f, env)
    local ok, result = pcall(f)
    if not ok then
        error("failed to call " .. result)
    end 
    return result
end

-- Active REPL sessions
-- port -> { buffer, scope, env }
local sessions = {}

-- Import the 8FL standard library into a REPL environment
function useStandardLibrary(env)
    env.use = function(lib)
        local libValue = require(lib)
        for k, v in pairs(libValue) do
            env[fennel.mangle(k)] = v
        end
    end
    env.use("seq")
    env.use("core")
    env.use("scales")
    env.use("patterns")
    env.use("renoise")
    env.use("api")
end

-- Start a REPL session on a given network socket. Sets up the isolated
-- environment and other bookkeeping.
function startSession(socket)
    local scope = fennel.scope()
    local env = {
        fennel = fennel,
        ___replLocals___ = {},
        scope = scope, -- delete?
        print = function(...)
            for _, s in ipairs(arg) do
                socket:send(s .. " ")
            end
            socket:send("\n")
        end
    }
    useStandardLibrary(env)
    -- not sure if there's a better way to do this...
    evalChunk("(require-macros :api-macros)", env, scope)
    setmetatable(env, { __index = _G })
    sessions[socket.peer_port] = {
        buffer = "",
        scope = scope,
        env = env
    }
end

function status(message)
    message = "[8FL] " .. message
    print(message)
    renoise.app():show_status(message)
end

-- Get the REPL session associated with a socket
function getSession(socket)
    return sessions[socket.peer_port]
end

-- Start the REPL server
-- TODO don't hardcode port
local server, err = renoise.Socket.create_server("0.0.0.0", 2020)

if err then
    -- give up if we couldn't start the server
    print(err)
else
    _G["8fl_server"] = server -- keep server from getting GC'd
    status("listening " .. server.local_address .. ":" .. server.local_port)
    server:run({
            socket_error = function(error_message)
            print("socket error!", error_message)
        end,

        socket_accepted = function(socket)
            -- send new clients the banner and REPL prompt, start a session
            banner(socket)
            socket:send("8FL> ")
            status("new connection " .. socket.peer_address .. ":" .. socket.peer_port)
            startSession(socket)
        end,

        socket_message = function(socket, message)
            -- for each message, grow the buffer associated with the socket
            local session = getSession(socket)
            message = message:match("^%s*(.*)%s*$")
            if #message == 0 then
                return
            end
            if message:gsub("%s+", "") == "::panic::" then
                session.buffer = ""
                socket:send("8fl> ")
                return
            end

            session.buffer = session.buffer .. message
            if balanced(session.buffer) then
                -- if the buffer is balanced, try to evaluate and send the
                -- result back
                local ok, result = pcall(evalChunk, session.buffer, session.env, session.scope)
                session.buffer = ""
                if ok then
                    socket:send(stringify(result) .. "\n8fl> ")
                else
                    socket:send("error: " .. result .. "\n8fl> ")
                end
            else
                -- if not balanced, send continuation prompt
                socket:send(".... ")
            end
        end
    })
end

-- The function to run at the start of a new pattern
local _new_pattern_func = nil

-- TODO should this be a coroutine?
local cycleSize = 2
local lastPatternNumber = nil

-- Schedule function to run at start of new pattern
function onpattern(f)
    lastPatternNumber = nil
    _new_pattern_func = f
end

renoise.tool().tool_will_unload_observable:add_notifier(function ()
    if _G["8fl_server"] then
        status("tool unloading, shutting down server")
        _G["8fl_server"]:close()
    end
end)

-- watch playback and invoke scheduled function (if any) when pattern loops
function start_app_idle_observable()    
    renoise.tool().app_idle_observable:add_notifier(function ()
        local song = renoise.song()
        -- ensure enough blank patterns
        while #song.patterns < cycleSize do
            song.sequencer:insert_new_pattern_at(#song.patterns + 1)
        end
        
        if song.transport.playing and _new_pattern_func then
            -- if pattern has not changed, bail out
            local currentPatternNumber = song.transport.playback_pos.sequence
            if lastPatternNumber == currentPatternNumber then
                return
            end
            lastPatternNumber = currentPatternNumber

            -- pattern has changed, render scheduled pattern into next pattern in sequence
            local nextPatternNumber = (currentPatternNumber % cycleSize) + 1
            local start = os.clock()
            local success, err = pcall(_new_pattern_func, nextPatternNumber)
            local elapsed = os.clock() - start
            if not success then
                status("error " .. err)
            else
                status(string.format("rendered pattern %d (%fs)",  nextPatternNumber, elapsed))
            end
        end
    end)
end

start_app_idle_observable()