@mekuru/audio 0.0.0-stage → 0.1.4
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.jp.md +3 -0
- package/README.md +4 -2
- package/package.json +23 -4
- package/src/index.tsx +36 -0
- package/src/wav.ts +80 -0
package/README.jp.md
ADDED
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @mekuru/audio
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Audio resources and WAV processing.
|
|
4
|
+
|
|
5
|
+
`AudioSource`, `Audio`, and `readAudio` are exported from `@mekuru/audio`. WAV duration, joining and PCM decoding are available from `@mekuru/audio/wav`. This package depends on core, never on speech engines or narrator.
|
package/package.json
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mekuru/audio",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
"version": "0.1.4",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "git+https://github.com/kanade-k-1228/mekuru.git",
|
|
7
|
+
"directory": "pkgs/audio"
|
|
8
|
+
},
|
|
9
|
+
"homepage": "https://github.com/kanade-k-1228/mekuru/blob/main/pkgs/audio/README.md",
|
|
10
|
+
"type": "module",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": "./src/index.tsx",
|
|
13
|
+
"./wav": "./src/wav.ts"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src"
|
|
17
|
+
],
|
|
18
|
+
"sideEffects": false,
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@mekuru/types": "^0.1.4"
|
|
21
|
+
},
|
|
22
|
+
"engines": {
|
|
23
|
+
"bun": ">=1.4"
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** @jsxImportSource @mekuru/types */
|
|
2
|
+
import { relative, resolve, sep } from "node:path";
|
|
3
|
+
import { fps, projectRoot, type At, type Time } from "@mekuru/types";
|
|
4
|
+
import { wavSeconds } from "./wav.ts";
|
|
5
|
+
/** A finite audio resource in the current video's frames, including trailing silence. */
|
|
6
|
+
export type AudioSource = Readonly<{
|
|
7
|
+
len: number;
|
|
8
|
+
label?: string;
|
|
9
|
+
volume?: number;
|
|
10
|
+
}> &
|
|
11
|
+
(Readonly<{ status: "draft" }> | Readonly<{ status: "ready"; src: string }>);
|
|
12
|
+
|
|
13
|
+
const frames = (seconds: number) => Math.ceil(seconds * fps() - 1e-6);
|
|
14
|
+
|
|
15
|
+
const frameCount = (name: string, value: number): number => {
|
|
16
|
+
if (!Number.isSafeInteger(value) || value < 0) throw new Error(`${name} must be a non-negative whole number of frames`);
|
|
17
|
+
return value;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const readAudio = async (src: string, options: { len?: Time; gap?: Time; label?: string; volume?: number } = {}): Promise<AudioSource> => {
|
|
21
|
+
const file = resolve(projectRoot(), src);
|
|
22
|
+
const gap = frameCount("gap", options.gap ?? 0);
|
|
23
|
+
const len = options.len ?? frames(wavSeconds(Buffer.from(await Bun.file(file).arrayBuffer()), file)) + gap;
|
|
24
|
+
return Object.freeze({ status: "ready", src: file, len: frameCount("len", len), label: options.label, volume: options.volume });
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const Audio = ({ source, id, at }: { source: AudioSource; id?: string; at?: At }) => (
|
|
28
|
+
<dur id={id} at={at} len={source.len}>
|
|
29
|
+
<audio
|
|
30
|
+
src={source.status === "ready" ? relative(projectRoot(), source.src).split(sep).join("/") : undefined}
|
|
31
|
+
draft={source.status === "draft"}
|
|
32
|
+
volume={source.volume}
|
|
33
|
+
label={source.label}
|
|
34
|
+
/>
|
|
35
|
+
</dur>
|
|
36
|
+
);
|
package/src/wav.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
interface Wav {
|
|
2
|
+
fmt: Buffer;
|
|
3
|
+
data: Buffer;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const parseWav = (bytes: Buffer, what: string): Wav => {
|
|
7
|
+
if (bytes.length < 12 || bytes.toString("latin1", 0, 4) !== "RIFF" || bytes.toString("latin1", 8, 12) !== "WAVE") {
|
|
8
|
+
throw new Error(`${what} is not a WAV file`);
|
|
9
|
+
}
|
|
10
|
+
let fmt: Buffer | undefined;
|
|
11
|
+
let data: Buffer | undefined;
|
|
12
|
+
for (let at = 12; at + 8 <= bytes.length; ) {
|
|
13
|
+
const id = bytes.toString("latin1", at, at + 4);
|
|
14
|
+
const size = Math.min(bytes.readUInt32LE(at + 4), bytes.length - at - 8);
|
|
15
|
+
if (id === "fmt ") fmt = bytes.subarray(at + 8, at + 8 + size);
|
|
16
|
+
if (id === "data") data = bytes.subarray(at + 8, at + 8 + size);
|
|
17
|
+
at += 8 + size + (size % 2);
|
|
18
|
+
}
|
|
19
|
+
if (!fmt || fmt.length < 16 || !data) throw new Error(`${what} is not a WAV file (no samples)`);
|
|
20
|
+
return { fmt, data };
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const wavSeconds = (bytes: Buffer, what = "the audio"): number => {
|
|
24
|
+
const { fmt, data } = parseWav(bytes, what);
|
|
25
|
+
const bytesPerSecond = fmt.readUInt32LE(8);
|
|
26
|
+
if (bytesPerSecond === 0) throw new Error(`${what} is not a WAV file (no sample rate)`);
|
|
27
|
+
return data.length / bytesPerSecond;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const joinWavs = (parts: Buffer[]): Buffer => {
|
|
31
|
+
const wavs = parts.map((p, i) => parseWav(p, `part ${i + 1}`));
|
|
32
|
+
const { fmt } = wavs[0];
|
|
33
|
+
if (wavs.some((w) => !w.fmt.equals(fmt))) throw new Error("the parts of the line have different audio formats");
|
|
34
|
+
const data = Buffer.concat(wavs.map((w) => w.data));
|
|
35
|
+
const head = Buffer.alloc(12 + 8 + fmt.length + (fmt.length % 2) + 8);
|
|
36
|
+
head.write("RIFF", 0, "latin1");
|
|
37
|
+
head.writeUInt32LE(head.length - 8 + data.length, 4);
|
|
38
|
+
head.write("WAVEfmt ", 8, "latin1");
|
|
39
|
+
head.writeUInt32LE(fmt.length, 16);
|
|
40
|
+
fmt.copy(head, 20);
|
|
41
|
+
head.write("data", head.length - 8, "latin1");
|
|
42
|
+
head.writeUInt32LE(data.length, head.length - 4);
|
|
43
|
+
return Buffer.concat([head, data]);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const samples = (wav: Buffer): { rate: number; data: Float32Array } => {
|
|
47
|
+
const { fmt, data } = parseWav(wav, "the audio");
|
|
48
|
+
const body = data;
|
|
49
|
+
let format = fmt.readUInt16LE(0);
|
|
50
|
+
if (format === 0xfffe && fmt.length >= 26) format = fmt.readUInt16LE(24);
|
|
51
|
+
const channels = Math.max(1, fmt.readUInt16LE(2));
|
|
52
|
+
const rate = fmt.readUInt32LE(4);
|
|
53
|
+
const bits = fmt.readUInt16LE(14);
|
|
54
|
+
const bytes = bits / 8;
|
|
55
|
+
const read: ((at: number) => number) | undefined =
|
|
56
|
+
format === 3 && bits === 32
|
|
57
|
+
? (at) => body.readFloatLE(at)
|
|
58
|
+
: format === 3 && bits === 64
|
|
59
|
+
? (at) => body.readDoubleLE(at)
|
|
60
|
+
: format !== 1
|
|
61
|
+
? undefined
|
|
62
|
+
: bits === 8
|
|
63
|
+
? (at) => (body.readUInt8(at) - 128) / 128
|
|
64
|
+
: bits === 16
|
|
65
|
+
? (at) => body.readInt16LE(at) / 32768
|
|
66
|
+
: bits === 24
|
|
67
|
+
? (at) => body.readIntLE(at, 3) / 8388608
|
|
68
|
+
: bits === 32
|
|
69
|
+
? (at) => body.readInt32LE(at) / 2147483648
|
|
70
|
+
: undefined;
|
|
71
|
+
if (!read || rate === 0) throw new Error(`the WAV format is not supported (format ${format}, ${bits} bit)`);
|
|
72
|
+
const n = Math.floor(data.length / (bytes * channels));
|
|
73
|
+
const out = new Float32Array(n);
|
|
74
|
+
for (let i = 0; i < n; i++) {
|
|
75
|
+
let sum = 0;
|
|
76
|
+
for (let c = 0; c < channels; c++) sum += read((i * channels + c) * bytes);
|
|
77
|
+
out[i] = sum / channels;
|
|
78
|
+
}
|
|
79
|
+
return { rate, data: out };
|
|
80
|
+
};
|