Monday, 29 April 2024

bitzliten: sound looper with Svelte 5



Following on from the basics,

we now want the nice features.

Recap

<Zone />

Loads a file to use, 

// these reset per file:

// the selection
let sel = $state({})
// push|pull their modes/seek|length representation
let sel_dominant = true

Uses sel to make up jobs:

// generate a bunch of tiles for your ears to walk on
function update_playlets() {
let {n_chunks} = get_timespace(sel)
// a set of dublets stretching across it
let nublets = create_unfulfilled_dublets(sel,n_chunks,chunk_length)

And passes a bunch of stuff to:

<Schaud {playlets} {needle_uplink} {sel}/>

...which is mostly about:

type acuelet = adublet & {startTime:number,source,needle,el}
let cuenow:null|adublet = null
// a new cuenow needs to .source and .play()
function scheduleNextSound(c?) {
...
// this shall remain the same for source.onended()'s callback
let cuelet = cuenow
// one of these while playing
if (!cuenow.source) {
    ...
let source = cuenow.source = source_cuelet(cuenow)
    ...
// play
cuenow.startTime = audioContext.currentTime
source.start(audioContext.currentTime);
    ...
source.onended = () => {
// on the cuelet that was cuenow when it started playing
delete cuelet.source
delete cuelet.needle
// cuenext will already be cuenow and playing (has .source) if crossfading
if (cuelet == cuenow) {
// nobody else (crossfading, ~cuelets) has altered cuenow since we started
cuenow = cuenext
scheduleNextSound();
}
};
}

Notice we keep in let cuelet what cuenow was, so we can compare it during callbacks.

So there's little structure to time.

Onward

I find that sel does not like being set from other components, despite being a $state.


The Zone side hosts an input() function:

// reset per file
let last_file
$effect(() => {
if (file && file != last_file) {
sel = {in:40,out:46}
sel_dominant = true
dublets = []
playlets = []
last_file = file
sel.input = (o) => sel_input(o)
}
})
let le
// ~sel -> modes
$effect(() => {
let see = sel.in
le = see
console.log("~sel ",sel)
})
// these in|out or start|end come from yonder
function sel_input(o) {
if (o.in != null) sel.in = o.in
sel_to_modes()
// will letsgo() if modes changes
}

Weirdly, that console.log("~sel ",sel) is never reached unless it sets le = see.

The input to sel comes from Schaud:

let selin = $state()
let selout = $state()
let selmo = $state()
let sel_adjustable = $state(false)
$effect(() => {
if (selin == null) return
let o = {in: selin}
console.log("Shaud -> sel",o)
sel.input(o)
})
$effect(() => {
if (sel?.out) {
sel_adjustable = true
selin = sel.in
selout = sel.out
let o = {in: selin}
console.log("Shaud <- sel",o)
}
})

If these two $effect()s are written the other way around, with the pull|import direction first, your change to eg selin via:

                    <Knob min={sel.in-10} max={sel.in+10}
bind:value={selin} ></Knob>

will be overwritten by selin = sel.in before {in: selin} is taken, etc.

The order being important was mentioned in the docs.

I wander along for a while and lose the ability to not be overwritten by selin = sel.in anyway.

When I crank in up, it logs this:


Unless I:

$inspect(sel)
$inspect(selin)
// pull from sel
let precise = $state('')
$effect(() => {
if (sel?.out) {
sel_adjustable = true
setTimeout(() => {
selin = sel.in
selout = sel.out
},10)
// precise = sel.start +' -- '+ sel.end
let o = {in:selin,out:selout}
console.log("Shaud <- sel",o)
}
})

Which then logs:


I decide to lean into {#each selections as se (se.id)}

As I did for needles, see 'must be declared in the top level of the component'

Svelte 5: snippets allow you to inject content, eg:

<Schaud {playlets} {needle_uplink} {sel} {on_reselection}>
{#snippet leftend(width_per_s)}
<span>
in
<Knob min={sel.in-10} max={sel.in+10}
bind:value={selin}
commit={push_to_sel} ></Knob>

So Schaud can decide the scale of time for visual feedback... At some point.

I am reading around for clues to master this Knob's reactivity.

Separating selection to Selection feels good. I try this:

// accept adjustments, may quickly adjust play
// set into an object owned by Zone...
sel.set({
in_time: writable(50),
out_time: writable(56),
})

This goes wrong.

Somewhere

Zone supplies the many sel with playlets: 2 second chunks of encoded audio, covering the selection.

let {sel,needle_uplink,on_reselection,chunk_length} = $props()

let playlets = $state([])
$effect(() => {
playlets = sel.playlets || []
})

It only matters to Zone if the selection changes enough to alter playlets, so we figure that out:

// accept adjustments, may quickly adjust play
// set into an object owned by Zone...
let in_time = $state(50)
let out_time = $state(56)
sel.set({
in_time,
out_time,
})
// shunt upwards when time needs expanding
$effect(() => {
// inclusively select dublet spaces
let fel = {
in: Math.floor(in_time / chunk_length) * chunk_length,
out: Math.ceil(out_time / chunk_length) * chunk_length,
}
if (fel.in != sel.in || fel.out != sel.out) {
let first_ever = sel.in == null
// non-reactively set it here
sel.set(fel)
console.log("Selection Woke",sel)
// then cause a reaction
!first_ever && on_reselection()
}
else {
console.log("Selection zzzz",sel)
}
})

As soon as in_time is <50 we must spawn a preceding cuelet|playlet|dublet|joblet (same thing in a different part of the production line), then until in_time < 48 we are done!




That should be in order. The production line involves syncing (primary keyed|indexed|resolved|deduplicated by each cuelet.in) into Schaud:


function sync_cuelets(playlets) {
let tr = transact_goners(cuelets)
let unhad:acuelet[] = cuelets.slice()
playlets.map((playlet:adublet) => {
let cuelet:any = cuelets.find((cuelet) => cuelet.in == playlet.in)
if (cuelet) {
tr.keep(cuelet)
}
else {
// create it
cuelet = {in:playlet.in,out:playlet.out}
cuelets.push(cuelet)
}
sync_cuelet(cuelet,playlet)
})
tr.done()
// and put them in the right order
cuelets = cuelets.sort((a,b) => a.in - b.in)
}
// maintain|whittle an array
// removing items without keep(z) before done().
// you may push more in meanwhile
function transact_goners(N) {
let unhad = N.slice()
return {
keep: (z) => {
unhad = unhad.filter(s => s != z)
},
done: (z) => {
let deletes = unhad.map((gone) => {
let i = N.indexOf(gone)
if (i < 0) throw "gone goner"
return i
})
deletes.reverse().map(i => N.splice(i,1))
},
}
}

Enormous subjects are best approached in thin, deep slices.

Nice

I found I could not transition those cuelets without them becoming zombies. This might be a bug:


Perhaps we should add billing

Svelte 5's Proxied state PR is interesting, the dwellings of the svelte community|vision are here.
I found my way onto their discord, surfing the threads it seemed a bit washed out, slightly confused cowboys scratching in the sand, near an invisible building.

The Reflect object in JavaScript provides a set of methods that allow you to perform interceptable operations on objects in a more functional way, often used in conjunction with Proxy objects for meta-programming and interception of object operations.

AlsoWith a web-of-trust, friends or family could vouch for your name, age or location; landlords could vouch for your address; employers could vouch for your skills; customers could vouch for businesses; and so on. As it doesn’t rely on government databases, but rather the people you know, it is truly decentralized and accessible.

A little more shoving things into libraries would be good now. I spring a reactivity problem immediately, in decodeAudio(cuelet):
// if (untrack(() => cuelet.buffer)) return
if (cuelet.buffer) return
This makes SYNC CLUELETS happen 4 times, when it does.
Whereas:
// if (untrack(() => cuelet.buffer)) return
// if (cuelet.buffer) return
cuelet.buffer && console.log("We already had a buffer")
Causes an infinite loop of noticing one thing already had a buffer about fifty times, then two things (we are doing the joblets), then I stop it after a hundred of them...
if (untrack(() => cuelet.buffer)) return
if (cuelet.buffer) return
// cuelet.buffer && console.log("We already had a buffer")
This makes SYNC CLUELETS happen 3 times, when it does.
And simply:
if (untrack(() => cuelet.buffer)) return
Makes it happen... twice.
Which is what we want, I believe.


Anyway that's in orch.sync_cuelets(playlets) now.
That's great to get out of Zone
    it feels maintainable again now.
I then move on to
    17 instances of audioContext

Here's a function in class ModusCueletSeq extends Modus
Any subclass of Modus provides this for momentary awareness, this one does the cuelets playing in sequence:
attend({def,c}) {
if (!this.orch.cuelets[0]) debugger

// c.go may be passed in|along
if (!this.cuenow) c.go = 1
if (c.go) {
this.cuenow = this.orch.next_cuelet(this.cuenow)
this.go_cuenow({def,c})
console.log("Playing "+this.cuenow.in)
}
}

Approximately this functionality was less abstract before in scheduleNextSound():

...
        // one of these while playing
if (!cuenow.source) {
remarks.push("new")
let source = cuenow.source = source_cuelet(cuenow)

// this will find the same one unless crossfading
let needle = cuenow.needle = find_unused_needle()
// crossfade from last
if (c.fadein) {
source.fadein(c.fadein)
}

// play
cuenow.startTime = audioContext.currentTime
            source.start(audioContext.currentTime);
...

The middle of ModusCueletSeq is:

go_cuenow({def,c}) {
if (!this.cuenow?.buffer) return console.warn("!ModusCueletSeq.cuenow.buffer",this)
// this one time we play this cuelet
// < can coexist with another time we play this same cuelet?
this.zip = new Ziplet({orch:this.orch, mo:this, cuelet:this.cuenow})
// crossfade from last
if (c.fadein) {
fadein(this.zip,c.fadein)
}
// play
this.zip.start()

this.plan_crossfade({def,c})
}

The fade stuff seems lopsided.
Perhaps Ziplet should do fades, by seeing what else is going on in mo:

class Ziplet {
    ...
constructor({orch,mo,cuelet}) {
        ...
// we may fade over the rest of whatever is playing
if (mo.zip) this.fade_in_over_zip = mo.zip
    ...
start() {
let time = this.orch.time
this.cuelet.startTime = time
this.source.start(time)
let ozip = this.fade_in_over_zip
if (ozip) this.fade_in_over(ozip)
} ...

this.orch.time is provided by modern Javascript: objects can have getters

We also use them here:

class Ziplet {
    ...
get duration():number {
let dur = this.source?.buffer?.duration
if (dur == null) throw "!duration"
return dur
}
get ends_at() {
if (this.cuelet.startTime == null) throw "!startTime"
return this.cuelet.startTime + this.duration
}
get time_left() {
let left = this.ends_at - this.orch.time
if (left < 0) left = 0
return left
}     ...

Ziplet == Needle

Perhaps Ziplet should do needles too - the representation of the play heads.
In Ziplet.start() we:
    Get a needle for it via this.orch.needle_uplink.find_unused_needle()
    Dispatch animation via this.orch.needle_uplink.up_displaytime()

They are both hanging out in Schaud.
    displaytime is reactive
    needles resides there
It keeps calling itself back to do other drudgery:

needle_uplink.up_displaytime = (the) => {
if (the && the != theone) return
the = theone = {}
setTimeout(() => up_displaytime(the), 166)

if (!needle_uplink.stat) return
let {cuenow,cue_time,loop_time} = needle_uplink.stat()
if (!(cuenow && cuenow.el)) return
displaytime = dec(loop_time)
check_time_is_passing(cue_time)
if (audioContext.state == 'running') needle_moves()
    }

We define needle_uplink.stat in ModusCueletSeq, it provides progress info for Schaud to be somewhere in time.

Here's where the needle moves:
function needle_moves() {
let {cuenow,cue_time,loop_time,zip} = needle_uplink.stat()
        ...
// 1-2 needles exist
let needle = zip.needle
        ...
needle.left.set(cuenow.el.offsetLeft*1 + some_left)
needle.left.set(cuenow.el.offsetLeft*1 + cueletswidth*1,{duration:duration*1000})
needle.top.set(cuenow.el.offsetTop)
        ...
}

And it feels its way along the blocks of time:


Looks to have broken the needle sharing - one is supposed to be used for each loop through.
We need to remove them from this list:

needle_uplink.find_unused_needle = () => {
        ...
        let used = cuelets.map(cuelet => cuelet.needle?.id)

when they finish:

export class ModusCueletSeq extends Modus {
    ...
plan_crossfade({def,c}) {
let zip = this.zip
let cuenow = this.cuenow
        ...
if (!continuity) {
// crossfading
            ...
        }
zip.source.onended = () => {
delete cuenow.needle
            ...


Thus commit: NeedleableZiplet

We're now back where we were at the start, but with better modelling.

We almost made it to new features
    before this blog post got laggy to type up
     in this WYSIWYG editor.

We have paved the way to controlling gain per Modus, abstracting the "playing cuelets" logic.

Perhaps we increased the purity of the code by one karat? Half a karat?

By the way, the word "carat" (or karat) comes from the carob seed, which was once used as a standard unit of measure due to its relatively consistent weight.

And along the way I made this great commit:





























No comments:

Post a Comment