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/pad.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pad — add silence to start and/or end of audio.
|
|
3
|
+
* pad(1) = 1s both sides. pad(1, 2) = 1s before, 2s after.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { seg } from '../plan.js'
|
|
7
|
+
|
|
8
|
+
const padPlan = (segs, ctx) => {
|
|
9
|
+
let { total, sampleRate: sr, args } = ctx
|
|
10
|
+
let before = args[0] ?? 0, after = args.length > 1 ? args[1] : before
|
|
11
|
+
let bN = Math.round(before * sr), aN = Math.round(after * sr)
|
|
12
|
+
let r = segs.map(s => { let n = s.slice(); n[2] = s[2] + bN; return n })
|
|
13
|
+
if (bN > 0) r.unshift(seg(0, bN, 0, undefined, null))
|
|
14
|
+
if (aN > 0) r.push(seg(0, aN, total + bN, undefined, null))
|
|
15
|
+
return r
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
import audio from '../core.js'
|
|
19
|
+
audio.op('pad', { plan: padPlan })
|
package/fn/pan.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pan — stereo balance control. -1 = full left, 0 = center, 1 = full right.
|
|
3
|
+
* Linear attenuation — never boosts, only attenuates one channel.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { opRange } from '../plan.js'
|
|
7
|
+
|
|
8
|
+
const pan = (chs, ctx) => {
|
|
9
|
+
let val = ctx.args[0] ?? 0 // -1..1 or t => value
|
|
10
|
+
if (chs.length < 2) return false // mono: no-op
|
|
11
|
+
let auto = typeof val === 'function'
|
|
12
|
+
let [s, end] = opRange(ctx, chs[0].length)
|
|
13
|
+
let off = (ctx.blockOffset || 0) * ctx.sampleRate
|
|
14
|
+
if (!auto) {
|
|
15
|
+
val = Math.max(-1, Math.min(1, val))
|
|
16
|
+
let gL = val <= 0 ? 1 : 1 - val, gR = val >= 0 ? 1 : 1 + val, gains = [gL, gR]
|
|
17
|
+
for (let c = 0; c < chs.length; c++) {
|
|
18
|
+
let g = gains[c] ?? 1
|
|
19
|
+
if (g === 1) continue
|
|
20
|
+
for (let i = Math.max(0, s); i < Math.min(end, chs[c].length); i++) chs[c][i] *= g
|
|
21
|
+
}
|
|
22
|
+
return chs
|
|
23
|
+
}
|
|
24
|
+
// Automation: per-sample evaluation
|
|
25
|
+
let L = chs[0], R = chs[1]
|
|
26
|
+
for (let i = Math.max(0, s); i < Math.min(end, L.length); i++) {
|
|
27
|
+
let p = Math.max(-1, Math.min(1, val((off + i) / ctx.sampleRate)))
|
|
28
|
+
L[i] *= p <= 0 ? 1 : 1 - p
|
|
29
|
+
R[i] *= p >= 0 ? 1 : 1 + p
|
|
30
|
+
}
|
|
31
|
+
return chs
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
import audio from '../core.js'
|
|
35
|
+
audio.op('pan', { process: pan })
|
package/fn/play.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import audio, { emit } from '../core.js'
|
|
2
|
+
import Speaker from 'audio-speaker'
|
|
3
|
+
|
|
4
|
+
/** Apply fade ramp to interleaved buffer. fadeIn=true ramps 0→1, fadeIn=false ramps 1→0 at end. */
|
|
5
|
+
function ramp(buf, ch, len, fadeIn, RAMP) {
|
|
6
|
+
let n = Math.min(RAMP, len)
|
|
7
|
+
if (fadeIn) {
|
|
8
|
+
for (let i = 0; i < n; i++) { let t = i / n; for (let c = 0; c < ch; c++) buf[i * ch + c] *= t }
|
|
9
|
+
} else {
|
|
10
|
+
let s = len - n
|
|
11
|
+
for (let i = 0; i < n; i++) { let t = 1 - i / n; for (let c = 0; c < ch; c++) buf[(s + i) * ch + c] *= t }
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Playback via audio-speaker (cross-platform: Node + browser). */
|
|
16
|
+
audio.fn.play = function(opts) {
|
|
17
|
+
let offset = opts?.at ?? 0, duration = opts?.duration
|
|
18
|
+
let a = this, BLOCK = audio.BLOCK_SIZE
|
|
19
|
+
if (a.playing) { a.playing = false; a.paused = false; if (a._._wake) a._._wake() }
|
|
20
|
+
a.playing = false; a.paused = opts?.paused ?? false; a.currentTime = offset
|
|
21
|
+
a.volume = opts?.volume ?? 0; a.loop = opts?.loop ?? false
|
|
22
|
+
a.block = null; a._._wake = null; a._._seekTo = null
|
|
23
|
+
|
|
24
|
+
;(async () => {
|
|
25
|
+
try {
|
|
26
|
+
let ch = a.channels, sr = a.sampleRate
|
|
27
|
+
a.playing = true
|
|
28
|
+
let wait = async () => { while (a.paused && a.playing && a._._seekTo == null) await new Promise(r => { a._._wake = r }); a._._wake = null }
|
|
29
|
+
|
|
30
|
+
let from = offset, RAMP = 256 // ~6ms anti-click ramp
|
|
31
|
+
while (a.playing) {
|
|
32
|
+
// If paused before playback starts (e.g. open without autoplay), wait silently
|
|
33
|
+
if (a.paused) {
|
|
34
|
+
await wait()
|
|
35
|
+
if (!a.playing) break
|
|
36
|
+
if (a._._seekTo != null) { from = a._._seekTo; a._._seekTo = null; a.currentTime = from }
|
|
37
|
+
}
|
|
38
|
+
let write = Speaker({ sampleRate: sr, channels: ch, bitDepth: 32 })
|
|
39
|
+
let seeked = false, played = 0, fadeIn = true
|
|
40
|
+
const flush = async () => {
|
|
41
|
+
let pad = new Uint8Array(BLOCK * ch * 4)
|
|
42
|
+
await new Promise(r => write(pad, r))
|
|
43
|
+
await new Promise(r => write(pad, r))
|
|
44
|
+
}
|
|
45
|
+
for await (let chunk of a.stream({at: from, duration})) {
|
|
46
|
+
if (!a.playing) break
|
|
47
|
+
let cLen = chunk[0].length
|
|
48
|
+
for (let bOff = 0; bOff < cLen; bOff += BLOCK) {
|
|
49
|
+
if (a._._seekTo != null) {
|
|
50
|
+
from = a._._seekTo; a._._seekTo = null
|
|
51
|
+
a.currentTime = from; seeked = true; break
|
|
52
|
+
}
|
|
53
|
+
let end = Math.min(bOff + BLOCK, cLen), len = end - bOff
|
|
54
|
+
a.block = chunk[0].subarray(bOff, end)
|
|
55
|
+
|
|
56
|
+
let g = 10 ** (a.volume / 20)
|
|
57
|
+
let buf = new Float32Array(len * ch)
|
|
58
|
+
for (let i = 0; i < len; i++) for (let c = 0; c < ch; c++)
|
|
59
|
+
buf[i * ch + c] = (chunk[c] || chunk[0])[bOff + i] * g
|
|
60
|
+
let send = () => new Promise(r => write(new Uint8Array(buf.buffer), r))
|
|
61
|
+
if (fadeIn) { ramp(buf, ch, len, true, RAMP); fadeIn = false }
|
|
62
|
+
|
|
63
|
+
if (a.paused) {
|
|
64
|
+
ramp(buf, ch, len, false, RAMP)
|
|
65
|
+
await send()
|
|
66
|
+
await flush()
|
|
67
|
+
played += len
|
|
68
|
+
a.currentTime = from + played / sr
|
|
69
|
+
await wait()
|
|
70
|
+
if (a._._seekTo != null) continue
|
|
71
|
+
if (!a.playing) break
|
|
72
|
+
fadeIn = true
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!a.playing) {
|
|
77
|
+
ramp(buf, ch, len, false, RAMP)
|
|
78
|
+
await send()
|
|
79
|
+
break
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
await send()
|
|
83
|
+
played += len
|
|
84
|
+
if (a._._seekTo == null) {
|
|
85
|
+
a.currentTime = from + played / sr
|
|
86
|
+
emit(a, 'timeupdate', a.currentTime)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (seeked || !a.playing) break
|
|
90
|
+
}
|
|
91
|
+
if (!seeked && !a.playing) await flush()
|
|
92
|
+
if (seeked) { await flush(); fadeIn = true }
|
|
93
|
+
write(null)
|
|
94
|
+
if (seeked) continue
|
|
95
|
+
if (!a.playing) break
|
|
96
|
+
if (a.loop) { from = 0; a.currentTime = 0; emit(a, 'timeupdate', 0); continue }
|
|
97
|
+
a.playing = false
|
|
98
|
+
emit(a, 'timeupdate', a.currentTime)
|
|
99
|
+
break
|
|
100
|
+
}
|
|
101
|
+
a.playing = false; emit(a, 'ended')
|
|
102
|
+
} catch (err) {
|
|
103
|
+
console.error('Playback error:', err)
|
|
104
|
+
a.playing = false
|
|
105
|
+
}
|
|
106
|
+
})()
|
|
107
|
+
return this
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let proto = audio.fn
|
|
111
|
+
proto.pause = function() { this.paused = true }
|
|
112
|
+
proto.resume = function() { this.paused = false; if (this._._wake) this._._wake() }
|
package/fn/remix.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const remix = (chs, ctx) => {
|
|
2
|
+
let arg = ctx.args[0], len = chs[0].length
|
|
3
|
+
// array map: [0, 1, null, ...] — number = source ch, null = silence
|
|
4
|
+
if (Array.isArray(arg)) {
|
|
5
|
+
return arg.map(src =>
|
|
6
|
+
src == null ? new Float32Array(len) : new Float32Array(chs[((src % chs.length) + chs.length) % chs.length])
|
|
7
|
+
)
|
|
8
|
+
}
|
|
9
|
+
let n = chs.length, m = arg
|
|
10
|
+
if (n === m) return false
|
|
11
|
+
if (m < n) {
|
|
12
|
+
let out = new Float32Array(len)
|
|
13
|
+
for (let c = 0; c < n; c++)
|
|
14
|
+
for (let i = 0; i < len; i++) out[i] += chs[c][i]
|
|
15
|
+
let inv = 1 / n
|
|
16
|
+
for (let i = 0; i < len; i++) out[i] *= inv
|
|
17
|
+
return Array.from({ length: m }, () => new Float32Array(out))
|
|
18
|
+
}
|
|
19
|
+
return Array.from({ length: m }, (_, c) => new Float32Array(chs[c % n]))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const remixCh = (_, args) => Array.isArray(args[0]) ? args[0].length : args[0]
|
|
23
|
+
|
|
24
|
+
import audio from '../core.js'
|
|
25
|
+
audio.op('remix', { process: remix, ch: remixCh })
|
package/fn/remove.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { seg, planOffset } from '../plan.js'
|
|
2
|
+
|
|
3
|
+
function removeSegs(segs, off, dur) {
|
|
4
|
+
let r = [], end = off + dur
|
|
5
|
+
for (let s of segs) {
|
|
6
|
+
let se = s[2] + s[1]
|
|
7
|
+
if (se <= off) r.push(s)
|
|
8
|
+
else if (s[2] >= end) { let n = s.slice(); n[2] = s[2] - dur; r.push(n) }
|
|
9
|
+
else {
|
|
10
|
+
let absR = Math.abs(s[3] || 1)
|
|
11
|
+
if (s[2] < off) r.push(seg(s[0], off - s[2], s[2], s[3], s[4]))
|
|
12
|
+
if (se > end) r.push(seg(s[0] + (end - s[2]) * absR, se - end, off, s[3], s[4]))
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return r
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const removePlan = (segs, ctx) => {
|
|
19
|
+
let { total } = ctx
|
|
20
|
+
let s = planOffset(ctx.offset, total)
|
|
21
|
+
return removeSegs(segs, s, Math.min(ctx.length ?? (total - s), total - s))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
import audio from '../core.js'
|
|
25
|
+
audio.op('remove', { plan: removePlan })
|
package/fn/repeat.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { cropSegs } from './crop.js'
|
|
2
|
+
import { seg, planOffset } from '../plan.js'
|
|
3
|
+
|
|
4
|
+
function repeatSegs(segs, times, total, off, dur) {
|
|
5
|
+
if (off == null) {
|
|
6
|
+
let r = []
|
|
7
|
+
for (let t = 0; t <= times; t++)
|
|
8
|
+
for (let s of segs) { let n = s.slice(); n[2] = s[2] + total * t; r.push(n) }
|
|
9
|
+
return r
|
|
10
|
+
}
|
|
11
|
+
let segLen = dur ?? (total - off), clip = cropSegs(segs, off, segLen), r = []
|
|
12
|
+
for (let s of segs) {
|
|
13
|
+
let se = s[2] + s[1]
|
|
14
|
+
if (se <= off + segLen) r.push(s)
|
|
15
|
+
else if (s[2] >= off + segLen) { let n = s.slice(); n[2] = s[2] + segLen * times; r.push(n) }
|
|
16
|
+
else {
|
|
17
|
+
let absR = Math.abs(s[3] || 1), split = off + segLen - s[2]
|
|
18
|
+
r.push(seg(s[0], split, s[2], s[3], s[4]))
|
|
19
|
+
r.push(seg(s[0] + split * absR, se - off - segLen, off + segLen * (times + 1), s[3], s[4]))
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
for (let t = 1; t <= times; t++)
|
|
23
|
+
for (let c of clip) { let n = c.slice(); n[2] = off + segLen * t + c[2]; r.push(n) }
|
|
24
|
+
r.sort((a, b) => a[2] - b[2])
|
|
25
|
+
return r
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const repeatPlan = (segs, ctx) => {
|
|
29
|
+
let { total, args, length } = ctx
|
|
30
|
+
let off = ctx.offset != null ? planOffset(ctx.offset, total) : null
|
|
31
|
+
return repeatSegs(segs, args[0] ?? 1, total, off, length)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
import audio from '../core.js'
|
|
35
|
+
audio.op('repeat', { plan: repeatPlan })
|
package/fn/reverse.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { seg, planOffset } from '../plan.js'
|
|
2
|
+
|
|
3
|
+
function reverseSegs(segs, off, end) {
|
|
4
|
+
let r = []
|
|
5
|
+
for (let s of segs) {
|
|
6
|
+
let se = s[2] + s[1]
|
|
7
|
+
if (se <= off || s[2] >= end) { r.push(s); continue }
|
|
8
|
+
let absR = Math.abs(s[3] || 1)
|
|
9
|
+
if (s[2] < off) r.push(seg(s[0], off - s[2], s[2], s[3], s[4]))
|
|
10
|
+
let iStart = Math.max(s[2], off), iEnd = Math.min(se, end)
|
|
11
|
+
r.push(seg(s[0] + (iStart - s[2]) * absR, iEnd - iStart, off + end - iEnd, -(s[3] || 1), s[4]))
|
|
12
|
+
if (se > end) r.push(seg(s[0] + (end - s[2]) * absR, se - end, end, s[3], s[4]))
|
|
13
|
+
}
|
|
14
|
+
r.sort((a, b) => a[2] - b[2])
|
|
15
|
+
return r
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const reversePlan = (segs, ctx) => {
|
|
19
|
+
let { total, length } = ctx
|
|
20
|
+
let s = planOffset(ctx.offset, total)
|
|
21
|
+
return reverseSegs(segs, s, s + (length ?? total - s))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
import audio from '../core.js'
|
|
25
|
+
audio.op('reverse', { plan: reversePlan })
|
package/fn/save.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import audio, { emit, parseTime } from '../core.js'
|
|
2
|
+
import encode from 'encode-audio'
|
|
3
|
+
|
|
4
|
+
const FMT_ALIAS = { aif: 'aiff', oga: 'ogg' }
|
|
5
|
+
|
|
6
|
+
function resolveFormat(fmt) { return FMT_ALIAS[fmt] || fmt || 'wav' }
|
|
7
|
+
|
|
8
|
+
/** Stream-encode audio: calls sink(buf) per chunk, returns sink(null) at end. */
|
|
9
|
+
async function encodeStream(inst, fmt, opts, sink) {
|
|
10
|
+
let enc = await encode[fmt]({ sampleRate: inst.sampleRate, channels: inst.channels, ...opts.meta })
|
|
11
|
+
let written = 0, tick = 0
|
|
12
|
+
for await (let chunk of inst.stream({ at: opts.at, duration: opts.duration })) {
|
|
13
|
+
let buf = await enc(chunk)
|
|
14
|
+
if (buf.length) await sink(buf)
|
|
15
|
+
written += chunk[0].length
|
|
16
|
+
if (++tick % 2 === 0) await new Promise(r => setTimeout(r, 0))
|
|
17
|
+
emit(inst, 'progress', { offset: written / inst.sampleRate, total: (opts.duration != null ? parseTime(opts.duration) : null) ?? inst.duration })
|
|
18
|
+
}
|
|
19
|
+
let final = await enc()
|
|
20
|
+
if (final.length) await sink(final)
|
|
21
|
+
return sink(null)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Encode audio to bytes. */
|
|
25
|
+
audio.fn.encode = async function(fmt, opts = {}) {
|
|
26
|
+
if (typeof fmt === 'object') { opts = fmt; fmt = undefined }
|
|
27
|
+
fmt = resolveFormat(fmt)
|
|
28
|
+
if (!encode[fmt]) throw new Error('Unknown format: ' + fmt)
|
|
29
|
+
let parts = []
|
|
30
|
+
await encodeStream(this, fmt, opts, buf => { if (buf) parts.push(buf) })
|
|
31
|
+
let total = parts.reduce((n, p) => n + p.length, 0)
|
|
32
|
+
let out = new Uint8Array(total), pos = 0
|
|
33
|
+
for (let p of parts) { out.set(p, pos); pos += p.length }
|
|
34
|
+
return out
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Save audio to file path (Node) or writable handle (browser). */
|
|
38
|
+
audio.fn.save = async function(target, opts = {}) {
|
|
39
|
+
let fmt = opts.format ?? (typeof target === 'string' ? target.split('.').pop() : 'wav')
|
|
40
|
+
fmt = resolveFormat(fmt)
|
|
41
|
+
if (!encode[fmt]) throw new Error('Unknown format: ' + fmt)
|
|
42
|
+
|
|
43
|
+
let write, finish
|
|
44
|
+
if (typeof target === 'string') {
|
|
45
|
+
let { createWriteStream } = await import('fs')
|
|
46
|
+
let ws = createWriteStream(target)
|
|
47
|
+
write = buf => { if (!ws.write(Buffer.from(buf))) return new Promise(r => ws.once('drain', r)) }
|
|
48
|
+
finish = () => new Promise((res, rej) => { ws.on('finish', res); ws.on('error', rej); ws.end() })
|
|
49
|
+
} else if (target?.write) {
|
|
50
|
+
write = buf => target.write(buf)
|
|
51
|
+
finish = () => target.close?.()
|
|
52
|
+
} else throw new Error('Invalid save target')
|
|
53
|
+
|
|
54
|
+
await encodeStream(this, fmt, opts, buf => buf ? write(buf) : finish?.())
|
|
55
|
+
}
|
package/fn/silence.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Silence detection stat — finds silent regions from block stats.
|
|
3
|
+
* a.stat('silence', { threshold?, minDuration? }) → [{ at, duration }, ...]
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import audio from '../core.js'
|
|
7
|
+
import { queryRange } from '../stats.js'
|
|
8
|
+
import { resolveThreshold, isLoud } from './trim.js'
|
|
9
|
+
|
|
10
|
+
audio.fn.silence = async function(opts) {
|
|
11
|
+
let { stats, ch, sr, from, to } = await queryRange(this, opts)
|
|
12
|
+
let bs = stats.blockSize
|
|
13
|
+
let minDur = opts?.minDuration ?? 0.1
|
|
14
|
+
let thresh = resolveThreshold(stats, ch, from, to, opts?.threshold)
|
|
15
|
+
|
|
16
|
+
// Scan blocks for silence
|
|
17
|
+
let segs = [], start = null
|
|
18
|
+
for (let i = from; i < to; i++) {
|
|
19
|
+
if (!isLoud(stats, i, ch, thresh)) {
|
|
20
|
+
if (start == null) start = i
|
|
21
|
+
} else if (start != null) {
|
|
22
|
+
let segAt = start * bs / sr, segEnd = i * bs / sr
|
|
23
|
+
if (segEnd - segAt >= minDur) segs.push({ at: segAt, duration: segEnd - segAt })
|
|
24
|
+
start = null
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
// Trailing silence
|
|
28
|
+
if (start != null) {
|
|
29
|
+
let segAt = start * bs / sr, segEnd = Math.min(to * bs / sr, this.duration)
|
|
30
|
+
if (segEnd - segAt >= minDur) segs.push({ at: segAt, duration: segEnd - segAt })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return segs
|
|
34
|
+
}
|
package/fn/spectrum.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mel-frequency spectrum — FFT → mel-binned magnitudes.
|
|
3
|
+
* Core analysis primitive for spectrum display, spectrogram, feature extraction.
|
|
4
|
+
* Used by stat system (`a.stat('spectrum')`) and CLI playback visualization.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import fft from 'fourier-transform'
|
|
8
|
+
import hann from 'window-function/hann'
|
|
9
|
+
import { a as aWeight } from 'a-weighting'
|
|
10
|
+
|
|
11
|
+
// ── Mel scale ───────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
export let toMel = f => 2595 * Math.log10(1 + f / 700)
|
|
14
|
+
export let fromMel = m => 700 * (10 ** (m / 2595) - 1)
|
|
15
|
+
|
|
16
|
+
// ── Window cache ────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
let windows = {}
|
|
19
|
+
function hannWin(n) {
|
|
20
|
+
if (windows[n]) return windows[n]
|
|
21
|
+
let w = new Float32Array(n)
|
|
22
|
+
for (let i = 0; i < n; i++) w[i] = hann(i, n)
|
|
23
|
+
return (windows[n] = w)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ── Core ────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Compute mel-binned magnitude spectrum from a block of samples.
|
|
30
|
+
* @param {Float32Array} samples — mono PCM block (length should be power of 2)
|
|
31
|
+
* @param {number} sr — sample rate
|
|
32
|
+
* @param {object} [opts]
|
|
33
|
+
* @param {number} [opts.bins=128] — number of mel frequency bins
|
|
34
|
+
* @param {number} [opts.fMin=30] — minimum frequency Hz
|
|
35
|
+
* @param {number} [opts.fMax] — maximum frequency Hz (default: min(sr/2, 20000))
|
|
36
|
+
* @param {boolean} [opts.weight=true] — apply A-weighting (perceptual loudness)
|
|
37
|
+
* @returns {Float32Array} magnitude per mel bin (linear scale)
|
|
38
|
+
*/
|
|
39
|
+
export function melSpectrum(samples, sr, opts = {}) {
|
|
40
|
+
let { bins = 128, fMin = 30, fMax = Math.min(sr / 2, 20000), weight = true } = opts
|
|
41
|
+
let N = samples.length, win = hannWin(N)
|
|
42
|
+
let buf = new Float32Array(N)
|
|
43
|
+
for (let i = 0; i < N; i++) buf[i] = samples[i] * win[i]
|
|
44
|
+
let mag = fft(buf)
|
|
45
|
+
|
|
46
|
+
let mMin = toMel(fMin), mMax = toMel(fMax), binHz = sr / N
|
|
47
|
+
let out = new Float32Array(bins)
|
|
48
|
+
|
|
49
|
+
for (let b = 0; b < bins; b++) {
|
|
50
|
+
let f0 = fromMel(mMin + (mMax - mMin) * b / bins)
|
|
51
|
+
let f1 = fromMel(mMin + (mMax - mMin) * (b + 1) / bins)
|
|
52
|
+
let k0 = Math.max(1, Math.floor(f0 / binHz))
|
|
53
|
+
let k1 = Math.min(mag.length - 1, Math.ceil(f1 / binHz))
|
|
54
|
+
let sum = 0, cnt = 0
|
|
55
|
+
for (let k = k0; k <= k1; k++) { sum += mag[k] ** 2; cnt++ }
|
|
56
|
+
let rms = cnt > 0 ? Math.sqrt(sum / cnt) : 0
|
|
57
|
+
if (weight) rms *= aWeight((f0 + f1) / 2, sr)
|
|
58
|
+
out[b] = rms
|
|
59
|
+
}
|
|
60
|
+
return out
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
// ── Block analysis helper ───────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
/** Stream ch0, buffer remainder, call fn(block, acc) per N-sample block. Returns {acc, cnt}. */
|
|
67
|
+
export async function analyzeBlocks(inst, opts, N, bins, fn) {
|
|
68
|
+
let acc = new Float64Array(bins), cnt = 0, rem = new Float32Array(0)
|
|
69
|
+
for await (let pcm of inst.stream({ at: opts?.at, duration: opts?.duration })) {
|
|
70
|
+
let ch0 = pcm[0]
|
|
71
|
+
if (!ch0 || !ch0.length) continue
|
|
72
|
+
let input = ch0
|
|
73
|
+
if (rem.length) {
|
|
74
|
+
input = new Float32Array(rem.length + ch0.length)
|
|
75
|
+
input.set(rem, 0)
|
|
76
|
+
input.set(ch0, rem.length)
|
|
77
|
+
}
|
|
78
|
+
let limit = input.length - (input.length % N)
|
|
79
|
+
for (let off = 0; off < limit; off += N) { fn(input.subarray(off, off + N), acc); cnt++ }
|
|
80
|
+
rem = limit < input.length ? input.slice(limit) : new Float32Array(0)
|
|
81
|
+
}
|
|
82
|
+
return { acc, cnt }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Stat registration ───────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
import audio from '../core.js'
|
|
88
|
+
|
|
89
|
+
/** a.stat('spectrum', {bins}) → average mel spectrum in dB over range */
|
|
90
|
+
audio.fn.spectrum = async function(opts) {
|
|
91
|
+
let bins = opts?.bins ?? 128
|
|
92
|
+
let spectOpts = { bins, fMin: opts?.fMin, fMax: opts?.fMax, weight: opts?.weight }
|
|
93
|
+
let sr = this.sampleRate
|
|
94
|
+
|
|
95
|
+
let { acc, cnt } = await analyzeBlocks(this, opts, 1024, bins, (block, acc) => {
|
|
96
|
+
let mag = melSpectrum(block, sr, spectOpts)
|
|
97
|
+
for (let b = 0; b < bins; b++) acc[b] += mag[b] ** 2
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
if (cnt === 0) return new Float32Array(bins)
|
|
101
|
+
let out = new Float32Array(bins)
|
|
102
|
+
for (let b = 0; b < bins; b++) out[b] = 20 * Math.log10(Math.sqrt(acc[b] / cnt) + 1e-10)
|
|
103
|
+
return out
|
|
104
|
+
}
|
package/fn/speed.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Speed — change playback rate. speed(2) = double speed (half duration),
|
|
3
|
+
* speed(0.5) = half speed (double duration), speed(-1) = reverse.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { seg } from '../plan.js'
|
|
7
|
+
|
|
8
|
+
const speedPlan = (segs, ctx) => {
|
|
9
|
+
let rate = ctx.args[0]
|
|
10
|
+
if (rate === 0) throw new RangeError('speed: rate cannot be 0')
|
|
11
|
+
if (!rate || rate === 1) return segs
|
|
12
|
+
let absR = Math.abs(rate)
|
|
13
|
+
let r = [], dst = 0
|
|
14
|
+
for (let s of segs) {
|
|
15
|
+
let count = Math.round(s[1] / absR)
|
|
16
|
+
r.push(seg(s[0], count, dst, s[4] === null ? s[3] : (s[3] || 1) * rate, s[4]))
|
|
17
|
+
dst += count
|
|
18
|
+
}
|
|
19
|
+
return r
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
import audio from '../core.js'
|
|
23
|
+
audio.op('speed', { plan: speedPlan })
|
package/fn/split.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import audio, { parseTime } from '../core.js'
|
|
2
|
+
|
|
3
|
+
/** Split at offsets, returning views. No copies. */
|
|
4
|
+
audio.fn.split = function(...args) {
|
|
5
|
+
let offsets = (Array.isArray(args[0]) ? args[0] : args).map(parseTime)
|
|
6
|
+
let dur = this.duration
|
|
7
|
+
let cuts = [0, ...offsets.sort((a, b) => a - b).filter(t => t > 0 && t < dur), dur]
|
|
8
|
+
return cuts.slice(0, -1).map((start, i) => this.clip({at: start, duration: cuts[i + 1] - start}))
|
|
9
|
+
}
|
package/fn/stat.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import audio from '../core.js'
|
|
2
|
+
|
|
3
|
+
let rMin = (src, from, to) => { let v = Infinity; for (let i = from; i < to; i++) if (src[i] < v) v = src[i]; return v === Infinity ? 0 : v }
|
|
4
|
+
let rMax = (src, from, to) => { let v = -Infinity; for (let i = from; i < to; i++) if (src[i] > v) v = src[i]; return v === -Infinity ? 0 : v }
|
|
5
|
+
let rSum = (src, from, to) => { let v = 0; for (let i = from; i < to; i++) v += src[i]; return v }
|
|
6
|
+
let rMean = (src, from, to) => { let n = to - from; if (!n) return 0; let v = 0; for (let i = from; i < to; i++) v += src[i]; return v / n }
|
|
7
|
+
let rRms = (src, from, to) => { let n = to - from; if (!n) return 0; let v = 0; for (let i = from; i < to; i++) v += src[i]; return Math.sqrt(v / n) }
|
|
8
|
+
|
|
9
|
+
audio.stat('min', {
|
|
10
|
+
block: (chs) => chs.map(ch => {
|
|
11
|
+
let mn = Infinity
|
|
12
|
+
for (let i = 0; i < ch.length; i++) if (ch[i] < mn) mn = ch[i]
|
|
13
|
+
return mn
|
|
14
|
+
}),
|
|
15
|
+
reduce: rMin,
|
|
16
|
+
query: (stats, chs, from, to) => {
|
|
17
|
+
let v = Infinity
|
|
18
|
+
for (let c of chs) for (let i = from; i < Math.min(to, stats.min[c].length); i++) if (stats.min[c][i] < v) v = stats.min[c][i]
|
|
19
|
+
return v === Infinity ? 0 : v
|
|
20
|
+
}
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
audio.stat('max', {
|
|
24
|
+
block: (chs) => chs.map(ch => {
|
|
25
|
+
let mx = -Infinity
|
|
26
|
+
for (let i = 0; i < ch.length; i++) if (ch[i] > mx) mx = ch[i]
|
|
27
|
+
return mx
|
|
28
|
+
}),
|
|
29
|
+
reduce: rMax,
|
|
30
|
+
query: (stats, chs, from, to) => {
|
|
31
|
+
let v = -Infinity
|
|
32
|
+
for (let c of chs) for (let i = from; i < Math.min(to, stats.max[c].length); i++) if (stats.max[c][i] > v) v = stats.max[c][i]
|
|
33
|
+
return v === -Infinity ? 0 : v
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
audio.stat('dc', {
|
|
38
|
+
block: (chs) => chs.map(ch => {
|
|
39
|
+
let sum = 0
|
|
40
|
+
for (let i = 0; i < ch.length; i++) sum += ch[i]
|
|
41
|
+
return sum / ch.length
|
|
42
|
+
}),
|
|
43
|
+
reduce: rMean
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
audio.stat('clipping', {
|
|
47
|
+
block: (chs) => chs.map(ch => {
|
|
48
|
+
let n = 0
|
|
49
|
+
for (let i = 0; i < ch.length; i++) if (ch[i] >= 1 || ch[i] <= -1) n++
|
|
50
|
+
return n
|
|
51
|
+
}),
|
|
52
|
+
reduce: rSum,
|
|
53
|
+
query: (stats, chs, from, to, sr) => {
|
|
54
|
+
let bs = stats.blockSize, times = []
|
|
55
|
+
for (let i = from; i < to; i++) {
|
|
56
|
+
let n = 0
|
|
57
|
+
for (let c of chs) n += stats.clipping[c][i] || 0
|
|
58
|
+
if (n > 0) times.push(i * bs / sr)
|
|
59
|
+
}
|
|
60
|
+
return new Float32Array(times)
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
audio.stat('rms', {
|
|
65
|
+
block: (chs) => chs.map(ch => {
|
|
66
|
+
let sum = 0
|
|
67
|
+
for (let i = 0; i < ch.length; i++) sum += ch[i] * ch[i]
|
|
68
|
+
return sum / ch.length
|
|
69
|
+
}),
|
|
70
|
+
reduce: rRms,
|
|
71
|
+
query: (stats, chs, from, to) => {
|
|
72
|
+
let sum = 0, n = 0
|
|
73
|
+
for (let c of chs)
|
|
74
|
+
for (let i = from; i < Math.min(to, stats.rms[c].length); i++) { sum += stats.rms[c][i]; n++ }
|
|
75
|
+
return n ? Math.sqrt(sum / n) : 0
|
|
76
|
+
}
|
|
77
|
+
})
|
package/fn/transform.js
ADDED
package/fn/trim.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import audio from '../core.js'
|
|
2
|
+
|
|
3
|
+
export function autoThreshold(energies) {
|
|
4
|
+
let vals = energies.filter(e => e > 0)
|
|
5
|
+
if (!vals.length) return -40
|
|
6
|
+
vals.sort((a, b) => a - b)
|
|
7
|
+
let floor = vals[Math.floor(vals.length * 0.1)]
|
|
8
|
+
return Math.max(-80, Math.min(-20, 10 * Math.log10(floor) + 12))
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Resolve dB threshold to linear. Auto-detects from energy stats when db is null. */
|
|
12
|
+
export function resolveThreshold(stats, ch, from, to, db) {
|
|
13
|
+
if (db == null) {
|
|
14
|
+
let energies = []
|
|
15
|
+
for (let c = 0; c < ch; c++) for (let i = from; i < to; i++) energies.push(stats.energy[c][i])
|
|
16
|
+
db = autoThreshold(energies)
|
|
17
|
+
}
|
|
18
|
+
return 10 ** (db / 20)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Check if block i is loud (any channel exceeds thresh). */
|
|
22
|
+
export let isLoud = (stats, i, ch, thresh) => {
|
|
23
|
+
for (let c = 0; c < ch; c++)
|
|
24
|
+
if (Math.max(Math.abs(stats.min[c][i]), Math.abs(stats.max[c][i])) > thresh) return true
|
|
25
|
+
return false
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const trim = (chs, ctx) => {
|
|
29
|
+
let threshold = ctx.args[0]
|
|
30
|
+
if (threshold == null) {
|
|
31
|
+
let energies = []
|
|
32
|
+
for (let c = 0; c < chs.length; c++)
|
|
33
|
+
for (let off = 0; off < chs[c].length; off += audio.BLOCK_SIZE) {
|
|
34
|
+
let end = Math.min(off + audio.BLOCK_SIZE, chs[c].length), sum = 0
|
|
35
|
+
for (let i = off; i < end; i++) sum += chs[c][i] * chs[c][i]
|
|
36
|
+
energies.push(sum / (end - off))
|
|
37
|
+
}
|
|
38
|
+
threshold = autoThreshold(energies)
|
|
39
|
+
}
|
|
40
|
+
let thresh = 10 ** (threshold / 20)
|
|
41
|
+
|
|
42
|
+
let len = chs[0].length, s = 0, e = len - 1
|
|
43
|
+
for (; s < len; s++) { let loud = false; for (let c = 0; c < chs.length; c++) if (Math.abs(chs[c][s]) > thresh) { loud = true; break }; if (loud) break }
|
|
44
|
+
for (; e >= s; e--) { let loud = false; for (let c = 0; c < chs.length; c++) if (Math.abs(chs[c][e]) > thresh) { loud = true; break }; if (loud) break }
|
|
45
|
+
e++
|
|
46
|
+
|
|
47
|
+
return s === 0 && e === len ? false : chs.map(ch => ch.slice(s, e))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const trimResolve = (args, { stats, sampleRate, totalDuration }) => {
|
|
51
|
+
if (!stats?.min || !stats?.energy) return null
|
|
52
|
+
let ch = stats.min.length, blocks = stats.min[0].length
|
|
53
|
+
let total = Math.round(totalDuration * sampleRate)
|
|
54
|
+
let thresh = resolveThreshold(stats, ch, 0, stats.energy[0].length, args[0])
|
|
55
|
+
|
|
56
|
+
let s = 0, e = blocks - 1
|
|
57
|
+
for (; s < blocks; s++) if (isLoud(stats, s, ch, thresh)) break
|
|
58
|
+
for (; e >= s; e--) if (isLoud(stats, e, ch, thresh)) break
|
|
59
|
+
e++
|
|
60
|
+
|
|
61
|
+
if (s === 0 && e === blocks) return false
|
|
62
|
+
if (s >= e) return { type: 'crop', args: [], at: 0, duration: 0 }
|
|
63
|
+
let startSample = s * audio.BLOCK_SIZE
|
|
64
|
+
let endSample = Math.min(e * audio.BLOCK_SIZE, total)
|
|
65
|
+
return { type: 'crop', args: [], at: startSample / sampleRate, duration: (endSample - startSample) / sampleRate }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
audio.op('trim', { process: trim, resolve: trimResolve })
|