audio 2.0.0-0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +647 -52
- package/audio.d.ts +232 -0
- package/audio.js +54 -0
- package/bin/cli.js +983 -0
- package/cache.js +89 -0
- package/core.js +602 -0
- package/dist/audio.all.js +105369 -0
- package/dist/audio.js +3490 -0
- package/dist/audio.min.js +14 -0
- package/fn/cepstrum.js +55 -0
- package/fn/clip.js +11 -0
- package/fn/crop.js +19 -0
- package/fn/fade.js +47 -0
- package/fn/filter.js +67 -0
- package/fn/gain.js +16 -0
- package/fn/insert.js +31 -0
- package/fn/loudness.js +102 -0
- package/fn/mix.js +21 -0
- package/fn/normalize.js +80 -0
- package/fn/pad.js +19 -0
- package/fn/pan.js +35 -0
- package/fn/play.js +112 -0
- package/fn/remix.js +25 -0
- package/fn/remove.js +25 -0
- package/fn/repeat.js +35 -0
- package/fn/reverse.js +25 -0
- package/fn/save.js +55 -0
- package/fn/silence.js +34 -0
- package/fn/spectrum.js +104 -0
- package/fn/speed.js +23 -0
- package/fn/split.js +9 -0
- package/fn/stat.js +77 -0
- package/fn/transform.js +6 -0
- package/fn/trim.js +68 -0
- package/fn/write.js +15 -0
- package/{LICENSE → license.md} +21 -21
- package/package.json +64 -19
- package/plan.js +456 -0
- package/stats.js +255 -0
- package/index.js +0 -107
package/fn/write.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { opRange } from '../plan.js'
|
|
2
|
+
|
|
3
|
+
const write = (chs, ctx) => {
|
|
4
|
+
let data = ctx.args[0]
|
|
5
|
+
let [p, end] = opRange(ctx, chs[0].length)
|
|
6
|
+
let srcOff = Math.max(0, -p), dstOff = Math.max(0, p)
|
|
7
|
+
for (let c = 0; c < chs.length; c++) {
|
|
8
|
+
let s = Array.isArray(data) ? (data[c] || data[0]) : data
|
|
9
|
+
for (let i = srcOff; i < s.length && dstOff + i - srcOff < end; i++) chs[c][dstOff + i - srcOff] = s[i]
|
|
10
|
+
}
|
|
11
|
+
return chs
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
import audio from '../core.js'
|
|
15
|
+
audio.op('write', { process: write })
|
package/{LICENSE → license.md}
RENAMED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
The MIT License (MIT)
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2016 Jamen Marzonie <jamenmarz@gmail.com> (http://jamenmarz.com)
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in
|
|
13
|
-
all copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
-
THE SOFTWARE.
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2016 Jamen Marzonie <jamenmarz@gmail.com> (http://jamenmarz.com)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
package/package.json
CHANGED
|
@@ -1,30 +1,75 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "audio",
|
|
3
|
-
"version": "2.0.0
|
|
4
|
-
"description": "
|
|
5
|
-
"
|
|
6
|
-
"main": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Audio loading, editing, and rendering for JavaScript",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "audio.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"browser": "./dist/audio.js",
|
|
10
|
+
"default": "./audio.js"
|
|
11
|
+
},
|
|
12
|
+
"./core": "./core.js",
|
|
13
|
+
"./plan": "./plan.js",
|
|
14
|
+
"./stats": "./stats.js",
|
|
15
|
+
"./cache": "./cache.js",
|
|
16
|
+
"./fn/*": "./fn/*"
|
|
17
|
+
},
|
|
18
|
+
"types": "./audio.d.ts",
|
|
19
|
+
"bin": "./bin/cli.js",
|
|
7
20
|
"license": "MIT",
|
|
21
|
+
"repository": "audiojs/audio",
|
|
8
22
|
"files": [
|
|
9
|
-
"
|
|
23
|
+
"core.js",
|
|
24
|
+
"audio.js",
|
|
25
|
+
"audio.d.ts",
|
|
26
|
+
"plan.js",
|
|
27
|
+
"stats.js",
|
|
28
|
+
"cache.js",
|
|
29
|
+
"fn/",
|
|
30
|
+
"bin/",
|
|
31
|
+
"dist/"
|
|
10
32
|
],
|
|
11
|
-
"scripts": {
|
|
12
|
-
"test": "eslint index.js test && tape 'test/*.js' | tap-spec"
|
|
13
|
-
},
|
|
14
|
-
"devDependencies": {
|
|
15
|
-
"eslint": "^2.11.0",
|
|
16
|
-
"eslint-config-google": "^0.5.0",
|
|
17
|
-
"tap-spec": "^4.1.1",
|
|
18
|
-
"tape": "^4.5.1"
|
|
19
|
-
},
|
|
20
33
|
"keywords": [
|
|
21
34
|
"audiojs",
|
|
22
35
|
"audio",
|
|
23
|
-
"
|
|
24
|
-
"pcm"
|
|
25
|
-
"lpcm"
|
|
36
|
+
"dsp",
|
|
37
|
+
"pcm"
|
|
26
38
|
],
|
|
27
|
-
"
|
|
28
|
-
"
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "node .esbuild.js",
|
|
41
|
+
"test": "node test/index.js",
|
|
42
|
+
"test:cli": "TST_PARALLEL=3 node test/cli.js",
|
|
43
|
+
"test:browser": "node test/browser.js",
|
|
44
|
+
"test:all": "CI=1 npm test && CI=1 npm run test:cli && npm run test:browser",
|
|
45
|
+
"version": "node -p \"var s=require('fs'),v=require('./package.json').version;s.writeFileSync('core.js',s.readFileSync('core.js','utf8').replace(/audio\\.version = '[^']+'/,'audio.version = \\''+v+'\\''));''\" && git add core.js",
|
|
46
|
+
"prepublishOnly": "npm run version && npm run test:all",
|
|
47
|
+
"serve": "node test/serve.js",
|
|
48
|
+
"demo": "vhs player.tape"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"@audio/decode-aiff": "^1.1.0",
|
|
52
|
+
"@audio/decode-caf": "^1.1.0",
|
|
53
|
+
"@audio/decode-wav": "^1.1.0",
|
|
54
|
+
"@audio/encode-mp3": "^1.0.1",
|
|
55
|
+
"a-weighting": "^2.0.1",
|
|
56
|
+
"audio-decode": "^3.8.1",
|
|
57
|
+
"audio-filter": "^2.2.2",
|
|
58
|
+
"audio-speaker": "^2.1.1",
|
|
59
|
+
"audio-type": "^2.4.1",
|
|
60
|
+
"encode-audio": "^1.2.2",
|
|
61
|
+
"fourier-transform": "^2.2.0",
|
|
62
|
+
"parse-duration": "^2.1.6",
|
|
63
|
+
"pcm-convert": "^3.1.1",
|
|
64
|
+
"window-function": "^3.0.1"
|
|
65
|
+
},
|
|
66
|
+
"optionalDependencies": {
|
|
67
|
+
"audio-mic": "^1.0.0"
|
|
68
|
+
},
|
|
69
|
+
"devDependencies": {
|
|
70
|
+
"audio-lena": "^3.0.0",
|
|
71
|
+
"esbuild": "^0.28.0",
|
|
72
|
+
"playwright": "^1.59.1",
|
|
73
|
+
"tst": "^9.4.0"
|
|
29
74
|
}
|
|
30
75
|
}
|
package/plan.js
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan — non-destructive edit pipeline.
|
|
3
|
+
* Intercepts create/run/read/stream to track and materialize edits.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import audio, { readPages, copyPages, walkPages, parseTime, LOAD, READ, emit } from './core.js'
|
|
7
|
+
|
|
8
|
+
let fn = audio.fn
|
|
9
|
+
let ops = {}
|
|
10
|
+
|
|
11
|
+
// ── Segments: [src, count, dst, rate?, ref?] ────────────────────
|
|
12
|
+
export function seg(src, count, dst, rate, ref) {
|
|
13
|
+
let s = [src, count, dst]
|
|
14
|
+
if (rate != null && rate !== 1) s[3] = rate
|
|
15
|
+
if (ref !== undefined) s[4] = ref
|
|
16
|
+
return s
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ── Range Helpers ────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
/** Normalize an offset in samples: negative = from-end, clamp to [0, total]. dflt used when offset is null. */
|
|
22
|
+
export function planOffset(offset, total, dflt = 0) {
|
|
23
|
+
let s = offset ?? dflt
|
|
24
|
+
if (s < 0) s = total + s
|
|
25
|
+
return Math.min(Math.max(0, s), total)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Compute [start, end] sample range from a process ctx (at/duration) over a buffer of given len. */
|
|
29
|
+
export function opRange(ctx, len) {
|
|
30
|
+
let sr = ctx.sampleRate
|
|
31
|
+
let s = ctx.at != null ? Math.round(ctx.at * sr) : 0
|
|
32
|
+
return [s, ctx.duration != null ? s + Math.round(ctx.duration * sr) : len]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── Op Registration ─────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
function isOpts(v) {
|
|
38
|
+
return v != null && typeof v === 'object' && !Array.isArray(v) && !ArrayBuffer.isView(v) && !v.pages && !v.getChannelData
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Register/query op: audio.op(name, descriptor|process) */
|
|
42
|
+
audio.op = function(name, arg1, arg2, arg3) {
|
|
43
|
+
if (!arguments.length) return ops
|
|
44
|
+
if (arguments.length === 1) return ops[name]
|
|
45
|
+
|
|
46
|
+
// Normalize to descriptor object
|
|
47
|
+
let desc
|
|
48
|
+
if (typeof arg1 !== 'function') {
|
|
49
|
+
desc = arg1
|
|
50
|
+
} else {
|
|
51
|
+
// Legacy positional: audio.op(name, process, plan?, opts?)
|
|
52
|
+
let plan, opts
|
|
53
|
+
if (typeof arg2 === 'function') { plan = arg2; opts = arg3 }
|
|
54
|
+
else opts = arg2
|
|
55
|
+
desc = { process: arg1 }
|
|
56
|
+
if (plan) desc.plan = plan
|
|
57
|
+
if (opts) Object.assign(desc, opts)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!fn[name] && !desc.hidden) {
|
|
61
|
+
let stdMethod = function(...a) {
|
|
62
|
+
let edit = { type: name, args: a }, last = a[a.length - 1]
|
|
63
|
+
if (a.length && isOpts(last)) {
|
|
64
|
+
let { at, duration, channel, offset, length, ...extra } = last
|
|
65
|
+
edit.args = a.slice(0, -1)
|
|
66
|
+
if (at != null) edit.at = parseTime(at)
|
|
67
|
+
if (duration != null) edit.duration = parseTime(duration)
|
|
68
|
+
if (offset != null) edit.offset = offset
|
|
69
|
+
if (length != null) edit.length = length
|
|
70
|
+
if (channel != null) edit.channel = channel
|
|
71
|
+
Object.assign(edit, extra)
|
|
72
|
+
}
|
|
73
|
+
return this.run(edit)
|
|
74
|
+
}
|
|
75
|
+
fn[name] = desc.call
|
|
76
|
+
? function(...a) { return desc.call.call(this, stdMethod, ...a) }
|
|
77
|
+
: stdMethod
|
|
78
|
+
}
|
|
79
|
+
ops[name] = desc
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
// ── Edit Tracking ───────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
/** Push an edit, bump version, notify. */
|
|
86
|
+
export function pushEdit(a, edit) {
|
|
87
|
+
a.edits.push(edit)
|
|
88
|
+
a.version++
|
|
89
|
+
emit(a, 'change')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Pop an edit, bump version, notify. */
|
|
93
|
+
export function popEdit(a) {
|
|
94
|
+
let e = a.edits.pop()
|
|
95
|
+
if (e) { a.version++; emit(a, 'change') }
|
|
96
|
+
return e
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
// ── Virtual Length/Channels ─────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
Object.defineProperties(fn, {
|
|
103
|
+
length: { get() {
|
|
104
|
+
if (this._.lenV === this.version) return this._.lenC
|
|
105
|
+
let len = this.edits.length ? buildPlan(this).totalLen : this._.len
|
|
106
|
+
this._.lenC = len; this._.lenV = this.version
|
|
107
|
+
return len
|
|
108
|
+
}, configurable: true },
|
|
109
|
+
channels: { get() {
|
|
110
|
+
if (this._.chV === this.version) return this._.chC
|
|
111
|
+
let ch = this._.ch
|
|
112
|
+
for (let edit of this.edits) { if (ops[edit.type]?.ch) ch = ops[edit.type].ch(ch, edit.args) }
|
|
113
|
+
this._.chC = ch; this._.chV = this.version
|
|
114
|
+
return ch
|
|
115
|
+
}, configurable: true },
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
// ── Read ───────────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
/** Ensure cache pages for the source ranges a plan will access. */
|
|
122
|
+
export async function ensurePlan(a, plan, offset, duration) {
|
|
123
|
+
if (!audio.ensurePages) return
|
|
124
|
+
let { segs, sr } = plan
|
|
125
|
+
let s = Math.round((offset || 0) * sr)
|
|
126
|
+
let e = duration != null ? s + Math.round(duration * sr) : plan.totalLen
|
|
127
|
+
for (let sg of segs) {
|
|
128
|
+
let iStart = Math.max(s, sg[2]), iEnd = Math.min(e, sg[2] + sg[1])
|
|
129
|
+
if (iStart >= iEnd) continue
|
|
130
|
+
let absR = Math.abs(sg[3] || 1)
|
|
131
|
+
let srcStart = sg[0] + (iStart - sg[2]) * absR
|
|
132
|
+
let srcLen = (iEnd - iStart) * absR + 1
|
|
133
|
+
let target = sg[4] === null ? null : sg[4] || a
|
|
134
|
+
if (target) await audio.ensurePages(target, srcStart / sr, srcLen / sr)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function loadRefs(a) {
|
|
139
|
+
for (let { args } of a.edits) if (args?.[0]?.pages) await args[0][LOAD]()
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
fn[READ] = async function(offset, duration) {
|
|
143
|
+
if (!this.edits.length) {
|
|
144
|
+
if (audio.ensurePages) await audio.ensurePages(this, offset, duration)
|
|
145
|
+
return readPages(this, offset, duration)
|
|
146
|
+
}
|
|
147
|
+
await this[LOAD]()
|
|
148
|
+
await loadRefs(this)
|
|
149
|
+
|
|
150
|
+
let plan = buildPlan(this)
|
|
151
|
+
await ensurePlan(this, plan, offset, duration)
|
|
152
|
+
return readPlan(this, plan, offset, duration)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
// ── Stream ─────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
fn[Symbol.asyncIterator] = fn.stream = async function*(opts) {
|
|
159
|
+
let offset = parseTime(opts?.at), duration = parseTime(opts?.duration)
|
|
160
|
+
// Live decode streaming (no edits, still decoding)
|
|
161
|
+
// Position-based: reads from full pages (zero-copy) or partial buffer (copied).
|
|
162
|
+
// Granularity = decoder chunk size, NOT page size. Pages are for memory, not streaming.
|
|
163
|
+
if (this._.waiters && !this.decoded && !this.edits.length) {
|
|
164
|
+
let sr = this.sampleRate, acc = this._.acc, PS = audio.PAGE_SIZE
|
|
165
|
+
let startSample = offset ? Math.round(offset * sr) : 0
|
|
166
|
+
let endSample = duration != null ? startSample + Math.round(duration * sr) : Infinity
|
|
167
|
+
let pos = startSample
|
|
168
|
+
while (pos < endSample) {
|
|
169
|
+
let available = acc ? this.pages.length * PS + acc.partialLen : this._.len
|
|
170
|
+
while (pos >= available && !this.decoded) {
|
|
171
|
+
await new Promise(r => this._.waiters.push(r))
|
|
172
|
+
available = acc ? this.pages.length * PS + acc.partialLen : this._.len
|
|
173
|
+
}
|
|
174
|
+
if (pos >= available) break
|
|
175
|
+
let end = Math.min(endSample, available)
|
|
176
|
+
let pi = Math.floor(pos / PS), po = pos % PS
|
|
177
|
+
if (pi < this.pages.length) {
|
|
178
|
+
let page = this.pages[pi], e = Math.min(page[0].length - po, end - pos)
|
|
179
|
+
yield page.map(ch => ch.subarray(po, po + e))
|
|
180
|
+
pos += e
|
|
181
|
+
} else if (acc) {
|
|
182
|
+
let e = Math.min(acc.partialLen - po, end - pos)
|
|
183
|
+
if (e > 0) { yield acc.partial.map(ch => ch.subarray(po, po + e).slice()); pos += e }
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Edit-aware streaming (plan-based)
|
|
190
|
+
await this.ready
|
|
191
|
+
await this[LOAD]()
|
|
192
|
+
await loadRefs(this)
|
|
193
|
+
let plan = buildPlan(this)
|
|
194
|
+
let seen = new Set()
|
|
195
|
+
for (let s of plan.segs) if (s[4] && s[4] !== null && !seen.has(s[4])) { seen.add(s[4]); await s[4][LOAD]() }
|
|
196
|
+
await ensurePlan(this, plan, offset, duration)
|
|
197
|
+
for (let chunk of streamPlan(this, plan, offset, duration)) yield chunk
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
// ── API ────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
fn.undo = function(n = 1) {
|
|
204
|
+
if (!this.edits.length) return n === 1 ? null : []
|
|
205
|
+
let removed = []
|
|
206
|
+
for (let i = 0; i < n && this.edits.length; i++) removed.push(popEdit(this))
|
|
207
|
+
return n === 1 ? removed[0] : removed
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
fn.run = function(...edits) {
|
|
211
|
+
let sr = this.sampleRate
|
|
212
|
+
for (let e of edits) {
|
|
213
|
+
if (!e.type) throw new TypeError('audio.run: edit must have type')
|
|
214
|
+
let edit = { ...e, args: e.args || [] }
|
|
215
|
+
if (edit.at != null) edit.at = parseTime(edit.at)
|
|
216
|
+
if (edit.duration != null) edit.duration = parseTime(edit.duration)
|
|
217
|
+
if (edit.offset != null) { edit.at = edit.offset / sr; delete edit.offset }
|
|
218
|
+
if (edit.length != null) { edit.duration = edit.length / sr; delete edit.length }
|
|
219
|
+
pushEdit(this, edit)
|
|
220
|
+
}
|
|
221
|
+
return this
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
fn.toJSON = function() {
|
|
225
|
+
let edits = this.edits.filter(e => !e.args?.some(a => typeof a === 'function'))
|
|
226
|
+
return { source: this.source, edits, sampleRate: this.sampleRate, channels: this.channels, duration: this.duration }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
fn.clone = function() {
|
|
230
|
+
let b = audio.from(this)
|
|
231
|
+
for (let e of this.edits) pushEdit(b, { ...e })
|
|
232
|
+
return b
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
// ── Render Engine ──────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
const MAX_FLAT_SIZE = 2 ** 29
|
|
239
|
+
|
|
240
|
+
/** Get sample length from any source type. */
|
|
241
|
+
export function srcLen(s) {
|
|
242
|
+
return Array.isArray(s) ? s[0].length : s?.getChannelData ? s.length : s._.len
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Render all edits into flat PCM, or read a slice. For ctx.render in PCM ops. */
|
|
246
|
+
export function render(a, offset, count) {
|
|
247
|
+
// Raw Float32Array[]
|
|
248
|
+
if (Array.isArray(a) && a[0] instanceof Float32Array) {
|
|
249
|
+
return offset != null ? a.map(ch => ch.subarray(offset, offset + count)) : a
|
|
250
|
+
}
|
|
251
|
+
// AudioBuffer
|
|
252
|
+
if (a?.getChannelData && !a.pages) {
|
|
253
|
+
let chs = Array.from({ length: a.numberOfChannels }, (_, i) => new Float32Array(a.getChannelData(i)))
|
|
254
|
+
return offset != null ? chs.map(ch => ch.subarray(offset, offset + count)) : chs
|
|
255
|
+
}
|
|
256
|
+
if (offset != null) return readRange(a, offset, count)
|
|
257
|
+
if (a._.pcm && a._.pcmV === a.version) return a._.pcm
|
|
258
|
+
if (!a.edits.length) { let r = readPages(a); a._.pcm = r; a._.pcmV = a.version; return r }
|
|
259
|
+
let plan = buildPlan(a)
|
|
260
|
+
let virtualLen = planLen(plan.segs)
|
|
261
|
+
if (virtualLen > MAX_FLAT_SIZE) throw new Error(`Audio too large for flat render (${(virtualLen / 1e6).toFixed(0)}M samples). Use streaming.`)
|
|
262
|
+
let r = readPlan(a, plan)
|
|
263
|
+
a._.pcm = r; a._.pcmV = a.version
|
|
264
|
+
return r
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function planLen(segs) { let m = 0; for (let s of segs) m = Math.max(m, s[2] + s[1]); return m }
|
|
268
|
+
|
|
269
|
+
/** Build a read plan from edit list. Always succeeds — every op is plannable. */
|
|
270
|
+
export function buildPlan(a) {
|
|
271
|
+
if (a._.plan && a._.planV === a.version) return a._.plan
|
|
272
|
+
let sr = a.sampleRate, ch = a._.ch
|
|
273
|
+
let segs = [[0, a._.len, 0]], pipeline = []
|
|
274
|
+
|
|
275
|
+
for (let edit of a.edits) {
|
|
276
|
+
let { type, args = [], at, duration, channel, ...extra } = edit
|
|
277
|
+
let op = ops[type]
|
|
278
|
+
if (!op) throw new Error(`Unknown op: ${type}`)
|
|
279
|
+
|
|
280
|
+
// resolve: try stats-aware replacement first
|
|
281
|
+
if (op.resolve) {
|
|
282
|
+
let ctx = { ...extra, stats: a._.srcStats || a.stats, sampleRate: sr, channelCount: ch, channel, at, duration, totalDuration: planLen(segs) / sr }
|
|
283
|
+
let resolved = op.resolve(args, ctx)
|
|
284
|
+
if (resolved === false) continue
|
|
285
|
+
if (resolved) {
|
|
286
|
+
let edits = Array.isArray(resolved) ? resolved : [resolved]
|
|
287
|
+
for (let r of edits) {
|
|
288
|
+
if (channel != null && r.channel == null) r.channel = channel
|
|
289
|
+
if (at != null && r.at == null) r.at = at
|
|
290
|
+
if (duration != null && r.duration == null) r.duration = duration
|
|
291
|
+
let rOp = ops[r.type]
|
|
292
|
+
if (rOp?.plan && typeof rOp.plan === 'function') {
|
|
293
|
+
let t = planLen(segs), rOffset = r.at != null ? Math.round(r.at * sr) : null, rLength = r.duration != null ? Math.round(r.duration * sr) : null
|
|
294
|
+
segs = rOp.plan(segs, { total: t, sampleRate: sr, args: r.args || [], offset: rOffset, length: rLength })
|
|
295
|
+
} else {
|
|
296
|
+
pipeline.push(r)
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
continue
|
|
300
|
+
}
|
|
301
|
+
// resolved null — fall through to plan or per-page
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// plan: structural segment rewrite
|
|
305
|
+
if (op.plan) {
|
|
306
|
+
let t = planLen(segs), offset = at != null ? Math.round(at * sr) : null, length = duration != null ? Math.round(duration * sr) : null
|
|
307
|
+
segs = op.plan(segs, { total: t, sampleRate: sr, args, offset, length })
|
|
308
|
+
} else {
|
|
309
|
+
pipeline.push(edit)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
let plan = { segs, pipeline, totalLen: planLen(segs), sr }
|
|
313
|
+
a._.plan = plan; a._.planV = a.version
|
|
314
|
+
return plan
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
// ── Plan Execution ─────────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
// Reusable resample buffer — avoids GC pressure during playback at non-unit rates
|
|
321
|
+
let _rsBuf = null, _rsLen = 0
|
|
322
|
+
|
|
323
|
+
/** Read channel samples from pages, resampled by rate. */
|
|
324
|
+
function readSource(a, c, srcOff, n, target, tOff, rate) {
|
|
325
|
+
let r = rate || 1, absR = Math.abs(r)
|
|
326
|
+
if (absR === 1) {
|
|
327
|
+
if (r > 0) return copyPages(a, c, srcOff, n, target, tOff)
|
|
328
|
+
return walkPages(a, c, srcOff, n, (pg, ch, s, e, off) => {
|
|
329
|
+
for (let i = s; i < e; i++) target[tOff + (n - 1 - (off + i - s))] = pg[ch][i]
|
|
330
|
+
})
|
|
331
|
+
}
|
|
332
|
+
let srcN = Math.ceil(n * absR) + 1
|
|
333
|
+
if (srcN > _rsLen) { _rsLen = srcN; _rsBuf = new Float32Array(srcN) }
|
|
334
|
+
let buf = _rsBuf.subarray(0, srcN)
|
|
335
|
+
buf.fill(0)
|
|
336
|
+
copyPages(a, c, srcOff, srcN, buf, 0)
|
|
337
|
+
resample(buf, target, tOff, n, r)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Linear interpolation resample: src buffer → n output samples at given rate. */
|
|
341
|
+
function resample(src, target, tOff, n, rate) {
|
|
342
|
+
let absR = Math.abs(rate), rev = rate < 0
|
|
343
|
+
for (let i = 0; i < n; i++) {
|
|
344
|
+
let pos = (rev ? n - 1 - i : i) * absR
|
|
345
|
+
let idx = pos | 0, frac = pos - idx
|
|
346
|
+
target[tOff + i] = idx + 1 < src.length
|
|
347
|
+
? src[idx] + (src[idx + 1] - src[idx]) * frac
|
|
348
|
+
: src[idx] || 0
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Read a sample range from an audio instance (handles edits via plan). */
|
|
353
|
+
function readRange(a, srcStart, n) {
|
|
354
|
+
if (!a.edits.length) {
|
|
355
|
+
return Array.from({ length: a._.ch }, (_, c) => {
|
|
356
|
+
let out = new Float32Array(n)
|
|
357
|
+
copyPages(a, c, srcStart, n, out, 0)
|
|
358
|
+
return out
|
|
359
|
+
})
|
|
360
|
+
}
|
|
361
|
+
let plan = buildPlan(a), sr = plan.sr
|
|
362
|
+
return readPlan(a, plan, srcStart / sr, n / sr)
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Stream chunks from a read plan. */
|
|
366
|
+
export function* streamPlan(a, plan, offset, duration) {
|
|
367
|
+
let { segs, pipeline, totalLen, sr } = plan
|
|
368
|
+
let s = Math.round((offset || 0) * sr), e = duration != null ? s + Math.round(duration * sr) : totalLen
|
|
369
|
+
|
|
370
|
+
let totalDur = totalLen / sr
|
|
371
|
+
let procs = pipeline.map(ed => {
|
|
372
|
+
let m = ops[ed.type]
|
|
373
|
+
let { type, args, at, duration, channel, ...extra } = ed
|
|
374
|
+
return {
|
|
375
|
+
op: m.process,
|
|
376
|
+
at: at != null && at < 0 ? totalDur + at : at,
|
|
377
|
+
dur: duration,
|
|
378
|
+
channel,
|
|
379
|
+
ctx: { ...extra, args: args || [], duration, sampleRate: sr, totalDuration: totalDur, render }
|
|
380
|
+
}
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
// Warm up stateful ops (filters) when seeking — render prior blocks silently to settle IIR state
|
|
384
|
+
let WARMUP = 8 // ~185ms at 44.1kHz — enough for most IIR filters to settle
|
|
385
|
+
let ws = (s > 0 && procs.length) ? Math.max(0, s - audio.BLOCK_SIZE * WARMUP) : s
|
|
386
|
+
|
|
387
|
+
for (let outOff = ws; outOff < e; outOff += audio.BLOCK_SIZE) {
|
|
388
|
+
let blockEnd = outOff < s ? s : e
|
|
389
|
+
let len = Math.min(audio.BLOCK_SIZE, blockEnd - outOff)
|
|
390
|
+
let chunk = Array.from({ length: a._.ch }, () => new Float32Array(len))
|
|
391
|
+
|
|
392
|
+
for (let sg of segs) {
|
|
393
|
+
let iStart = Math.max(outOff, sg[2]), iEnd = Math.min(outOff + len, sg[2] + sg[1])
|
|
394
|
+
if (iStart >= iEnd) continue
|
|
395
|
+
let rate = sg[3] || 1, ref = sg[4], absR = Math.abs(rate)
|
|
396
|
+
let n = iEnd - iStart, dstOff = iStart - outOff
|
|
397
|
+
// For negative rate, read from the far end of the source range so reversal is globally correct across blocks
|
|
398
|
+
let srcStart = rate < 0
|
|
399
|
+
? sg[0] + (sg[1] - (iStart - sg[2]) - n) * absR
|
|
400
|
+
: sg[0] + (iStart - sg[2]) * absR
|
|
401
|
+
if (ref === null) {
|
|
402
|
+
// zero-filled by default
|
|
403
|
+
} else if (ref) {
|
|
404
|
+
if (ref.edits.length === 0) {
|
|
405
|
+
for (let c = 0; c < a._.ch; c++)
|
|
406
|
+
readSource(ref, c % ref._.ch, srcStart, n, chunk[c], dstOff, rate)
|
|
407
|
+
} else {
|
|
408
|
+
let srcN = Math.ceil(n * absR) + 1
|
|
409
|
+
let srcPcm = readRange(ref, srcStart, srcN)
|
|
410
|
+
for (let c = 0; c < a._.ch; c++) {
|
|
411
|
+
let src = srcPcm[c % srcPcm.length]
|
|
412
|
+
if (absR === 1) {
|
|
413
|
+
if (rate < 0) { for (let i = 0; i < n; i++) chunk[c][dstOff + i] = src[n - 1 - i] }
|
|
414
|
+
else chunk[c].set(src.subarray(0, n), dstOff)
|
|
415
|
+
} else resample(src, chunk[c], dstOff, n, rate)
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
} else {
|
|
419
|
+
for (let c = 0; c < a._.ch; c++) readSource(a, c, srcStart, n, chunk[c], dstOff, rate)
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
let blockOff = outOff / sr
|
|
424
|
+
for (let proc of procs) {
|
|
425
|
+
let { op, at, channel, ctx } = proc
|
|
426
|
+
if (!op) continue
|
|
427
|
+
ctx.at = at != null ? at - blockOff : undefined
|
|
428
|
+
ctx.blockOffset = blockOff
|
|
429
|
+
|
|
430
|
+
if (channel != null) {
|
|
431
|
+
let chs = typeof channel === 'number' ? [channel] : channel
|
|
432
|
+
let sub = chs.map(c => chunk[c])
|
|
433
|
+
let result = op(sub, ctx)
|
|
434
|
+
if (result && result !== false) for (let i = 0; i < chs.length; i++) chunk[chs[i]] = result[i]
|
|
435
|
+
} else {
|
|
436
|
+
let result = op(chunk, ctx)
|
|
437
|
+
if (result === false || result === null) continue
|
|
438
|
+
if (result) chunk = result
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (outOff >= s) yield chunk
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function readPlan(a, plan, offset, duration) {
|
|
447
|
+
let chunks = []
|
|
448
|
+
for (let chunk of streamPlan(a, plan, offset, duration)) chunks.push(chunk)
|
|
449
|
+
if (!chunks.length) return Array.from({ length: a.channels }, () => new Float32Array(0))
|
|
450
|
+
let ch = chunks[0].length, totalLen = chunks.reduce((n, c) => n + c[0].length, 0)
|
|
451
|
+
return Array.from({ length: ch }, (_, c) => {
|
|
452
|
+
let out = new Float32Array(totalLen), pos = 0
|
|
453
|
+
for (let chunk of chunks) { out.set(chunk[c], pos); pos += chunk[0].length }
|
|
454
|
+
return out
|
|
455
|
+
})
|
|
456
|
+
}
|