@stacksjs/audio 0.70.258 → 0.70.259
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/dist/index.js +4 -173
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,173 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
function
|
|
4
|
-
|
|
5
|
-
if (!Number.isFinite(value) || value <= 0)
|
|
6
|
-
throw TypeError(`Audio ${name} must be positive`);
|
|
7
|
-
}
|
|
8
|
-
export function recommendAudioBitrate(format, source, content) {
|
|
9
|
-
const channels = Math.min(2, Math.max(1, source.channels)), rate = source.sampleRate <= 24000 ? 0.75 : source.sampleRate >= 88200 ? 1.15 : 1, base = content === "speech" ? channels === 1 ? 48000 : 64000 : content === "music" ? channels === 1 ? 96000 : 192000 : channels === 1 ? 64000 : 128000;
|
|
10
|
-
return Math.max(32000, Math.min(source.bitrate ?? 1 / 0, Math.round(base * rate * (format === "opus" ? 0.8 : format === "mp3" ? 1.2 : 1) / 1000) * 1000));
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export class AudioBuilder {
|
|
14
|
-
source;
|
|
15
|
-
inspected;
|
|
16
|
-
formats = ["opus", "aac", "mp3"];
|
|
17
|
-
contentType = "general";
|
|
18
|
-
targetLoudness = -16;
|
|
19
|
-
capabilities = { encoder: !1, codecs: [] };
|
|
20
|
-
constructor(source) {
|
|
21
|
-
this.source = source;
|
|
22
|
-
}
|
|
23
|
-
profile(value) {
|
|
24
|
-
validate(value);
|
|
25
|
-
this.inspected = value;
|
|
26
|
-
return this;
|
|
27
|
-
}
|
|
28
|
-
output(value) {
|
|
29
|
-
if (!value.length)
|
|
30
|
-
throw TypeError("Audio formats are required");
|
|
31
|
-
this.formats = [...new Set(value)];
|
|
32
|
-
return this;
|
|
33
|
-
}
|
|
34
|
-
content(value) {
|
|
35
|
-
this.contentType = value;
|
|
36
|
-
return this;
|
|
37
|
-
}
|
|
38
|
-
loudness(value) {
|
|
39
|
-
if (!Number.isFinite(value) || value < -70 || value > 0)
|
|
40
|
-
throw TypeError("Audio loudness must be between -70 and 0 LUFS");
|
|
41
|
-
this.targetLoudness = value;
|
|
42
|
-
return this;
|
|
43
|
-
}
|
|
44
|
-
runtime(value) {
|
|
45
|
-
this.capabilities = value;
|
|
46
|
-
return this;
|
|
47
|
-
}
|
|
48
|
-
generate() {
|
|
49
|
-
if (!this.inspected)
|
|
50
|
-
throw Error("Audio inspection is required; pass its profile with .profile()");
|
|
51
|
-
const profile = this.inspected, outputs = this.formats.map((format) => {
|
|
52
|
-
const info = details[format], copy = profile.codec === format && (profile.container === info.container || profile.container === info.extension), available = copy || this.capabilities.encoder && this.capabilities.codecs.includes(format);
|
|
53
|
-
return { format, ...info, bitrate: recommendAudioBitrate(format, profile, this.contentType), action: copy ? "copy" : "transcode", available, reason: available ? void 0 : `Native ${format} encoding is unavailable` };
|
|
54
|
-
});
|
|
55
|
-
return { source: this.source, profile, outputs, loudness: this.targetLoudness };
|
|
56
|
-
}
|
|
57
|
-
async process(options = {}) {
|
|
58
|
-
return processAudioPlan(this.generate(), options);
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
export function audio(source) {
|
|
62
|
-
return new AudioBuilder(source);
|
|
63
|
-
}
|
|
64
|
-
export async function processAudioPlan(plan, options = {}) {
|
|
65
|
-
assertAudioPlanExecutable(plan);
|
|
66
|
-
const { generateAudioDerivatives } = await import("@ts-audio/core/native-transcode");
|
|
67
|
-
return generateAudioDerivatives(plan.source, { source: plan.profile, outputs: plan.outputs }, options);
|
|
68
|
-
}
|
|
69
|
-
export function assertAudioPlanExecutable(plan) {
|
|
70
|
-
const missing = plan.outputs.filter((value) => !value.available);
|
|
71
|
-
if (missing.length)
|
|
72
|
-
throw Error(missing.map((value) => `${value.format}: ${value.reason}`).join("; "));
|
|
73
|
-
}
|
|
74
|
-
function q(accept, mime) {
|
|
75
|
-
const [type = "", subtype = ""] = (mime.split(";", 1)[0] ?? "").split("/");
|
|
76
|
-
let best = 0;
|
|
77
|
-
for (const item of accept.split(",")) {
|
|
78
|
-
const [range = "*/*", ...params] = item.trim().toLowerCase().split(";").map((value) => value.trim()), [a = "*", b = "*"] = range.split("/");
|
|
79
|
-
if ((a === "*" || a === type) && (b === "*" || b === subtype)) {
|
|
80
|
-
const raw = params.find((value) => value.startsWith("q="));
|
|
81
|
-
best = Math.max(best, raw ? Number.parseFloat(raw.slice(2)) || 0 : 1);
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
return best;
|
|
85
|
-
}
|
|
86
|
-
export function negotiateAudioOutput(outputs, accept = "*/*") {
|
|
87
|
-
return outputs.filter((value) => value.available).map((output, index) => ({ output, index, q: q(accept || "*/*", output.mimeType) })).filter((value) => value.q > 0).sort((a, b) => b.q - a.q || a.index - b.index)[0]?.output;
|
|
88
|
-
}
|
|
89
|
-
export function createWaveform(channels, sampleRate, samples = 1000, precision = 4) {
|
|
90
|
-
if (!channels.length || sampleRate <= 0)
|
|
91
|
-
throw TypeError("Waveform requires channels and a positive sample rate");
|
|
92
|
-
const frames = channels[0].length;
|
|
93
|
-
if (channels.some((channel) => channel.length !== frames))
|
|
94
|
-
throw TypeError("Waveform channels must have equal lengths");
|
|
95
|
-
const size = Math.max(1, Math.ceil(frames / Math.max(1, Math.min(frames || 1, Math.floor(samples)))));
|
|
96
|
-
let absolute = 0;
|
|
97
|
-
const raw = channels.map((channel) => {
|
|
98
|
-
const peaks = [];
|
|
99
|
-
for (let start = 0;start < frames; start += size) {
|
|
100
|
-
let min = 1, max = -1;
|
|
101
|
-
for (let index = start;index < Math.min(frames, start + size); index++) {
|
|
102
|
-
const sample = channel[index] ?? 0, value = Number.isFinite(sample) ? Math.max(-1, Math.min(1, sample)) : 0;
|
|
103
|
-
min = Math.min(min, value);
|
|
104
|
-
max = Math.max(max, value);
|
|
105
|
-
absolute = Math.max(absolute, Math.abs(value));
|
|
106
|
-
}
|
|
107
|
-
peaks.push([min, max]);
|
|
108
|
-
}
|
|
109
|
-
return peaks;
|
|
110
|
-
}), divisor = absolute || 1, round = (value) => Number((value / divisor).toFixed(Math.max(0, Math.min(6, precision))));
|
|
111
|
-
return { version: 1, channels: channels.length, sampleRate, duration: frames / sampleRate, samplesPerPeak: size, peaks: raw.map((channel) => channel.flatMap(([min, max]) => [round(min), round(max)])) };
|
|
112
|
-
}
|
|
113
|
-
function time(seconds) {
|
|
114
|
-
const ms = Math.round(seconds * 1000);
|
|
115
|
-
return `${String(Math.floor(ms / 3600000)).padStart(2, "0")}:${String(Math.floor(ms % 3600000 / 60000)).padStart(2, "0")}:${String(Math.floor(ms % 60000 / 1000)).padStart(2, "0")}.${String(ms % 1000).padStart(3, "0")}`;
|
|
116
|
-
}
|
|
117
|
-
export function normalizeTranscript(language, input) {
|
|
118
|
-
if (!language.trim())
|
|
119
|
-
throw TypeError("Transcript language is required");
|
|
120
|
-
let end = 0;
|
|
121
|
-
const segments = input.map((item, index) => {
|
|
122
|
-
if (item.startTime < end || item.startTime < 0 || item.endTime <= item.startTime || !item.text.trim())
|
|
123
|
-
throw TypeError(`Invalid transcript segment ${index}`);
|
|
124
|
-
if (item.confidence !== void 0 && (item.confidence < 0 || item.confidence > 1))
|
|
125
|
-
throw TypeError(`Invalid transcript confidence ${index}`);
|
|
126
|
-
end = item.endTime;
|
|
127
|
-
return { ...item, text: item.text.trim(), id: index + 1 };
|
|
128
|
-
}), lines = ["WEBVTT", ""];
|
|
129
|
-
for (const item of segments)
|
|
130
|
-
lines.push(String(item.id), `${time(item.startTime)} --> ${time(item.endTime)}`, item.speaker ? `<v ${item.speaker}>${item.text}` : item.text, "");
|
|
131
|
-
return { language: language.trim().toLowerCase(), segments, vtt: lines.join(`
|
|
132
|
-
`) };
|
|
133
|
-
}
|
|
134
|
-
export function audioResponseHeaders(bytes, etag, contentType) {
|
|
135
|
-
return { "Accept-Ranges": "bytes", "Content-Length": String(bytes), "Content-Type": contentType, "Cache-Control": "public, max-age=31536000, immutable", ETag: `"${etag}"`, Vary: "Accept" };
|
|
136
|
-
}
|
|
137
|
-
export function signAudioAsset(path, expires, secret) {
|
|
138
|
-
return createHmac("sha256", secret).update(`${path}
|
|
139
|
-
${expires}`).digest("base64url");
|
|
140
|
-
}
|
|
141
|
-
export function verifyAudioAsset(path, expires, signature, secret, now = Date.now()) {
|
|
142
|
-
if (!Number.isInteger(expires) || expires * 1000 <= now)
|
|
143
|
-
return !1;
|
|
144
|
-
const expected = Buffer.from(signAudioAsset(path, expires, secret)), actual = Buffer.from(signature);
|
|
145
|
-
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
|
146
|
-
}
|
|
147
|
-
export async function createProtectedAudioPlaylist(segments, protection) {
|
|
148
|
-
if (!segments.length)
|
|
149
|
-
throw TypeError("Audio playlist requires segments");
|
|
150
|
-
if (protection && /[\r\n"]/.test(protection.keyUri))
|
|
151
|
-
throw TypeError("Invalid audio key URI");
|
|
152
|
-
const files = {}, lines = ["#EXTM3U", "#EXT-X-VERSION:7", `#EXT-X-TARGETDURATION:${Math.ceil(Math.max(...segments.map((item) => item.duration)))}`, "#EXT-X-PLAYLIST-TYPE:VOD"];
|
|
153
|
-
for (const [index, segment] of segments.entries()) {
|
|
154
|
-
if (!Number.isFinite(segment.duration) || segment.duration <= 0 || /[\r\n"]/.test(segment.uri))
|
|
155
|
-
throw TypeError(`Invalid audio segment ${index}`);
|
|
156
|
-
let data = segment.data;
|
|
157
|
-
if (protection) {
|
|
158
|
-
if (protection.key.byteLength !== 16)
|
|
159
|
-
throw TypeError("Audio HLS AES-128 key must contain 16 bytes");
|
|
160
|
-
const iv = new Uint8Array(16);
|
|
161
|
-
new DataView(iv.buffer).setBigUint64(8, BigInt(index));
|
|
162
|
-
const key = Uint8Array.from(protection.key), input = Uint8Array.from(data), cryptoKey = await crypto.subtle.importKey("raw", key.buffer, { name: "AES-CBC" }, !1, ["encrypt"]);
|
|
163
|
-
data = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-CBC", iv: iv.buffer }, cryptoKey, input.buffer));
|
|
164
|
-
const ivHex = [...iv].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
165
|
-
lines.push(`#EXT-X-KEY:METHOD=AES-128,URI="${protection.keyUri}",IV=0x${ivHex}`);
|
|
166
|
-
}
|
|
167
|
-
lines.push(`#EXTINF:${segment.duration.toFixed(6)},`, segment.uri);
|
|
168
|
-
files[segment.uri] = data;
|
|
169
|
-
}
|
|
170
|
-
lines.push("#EXT-X-ENDLIST", "");
|
|
171
|
-
return { playlist: lines.join(`
|
|
172
|
-
`), files, encrypted: !!protection };
|
|
173
|
-
}
|
|
1
|
+
import{createHmac,timingSafeEqual}from"node:crypto";const details={opus:{container:"ogg",mimeType:"audio/ogg; codecs=opus",extension:"ogg"},aac:{container:"aac",mimeType:"audio/aac",extension:"aac"},mp3:{container:"mp3",mimeType:"audio/mpeg",extension:"mp3"}};function validate(profile){for(const[name,value]of Object.entries({duration:profile.duration,sampleRate:profile.sampleRate,channels:profile.channels}))if(!Number.isFinite(value)||value<=0)throw TypeError(`Audio ${name} must be positive`)}export function recommendAudioBitrate(format,source,content){const channels=Math.min(2,Math.max(1,source.channels)),rate=source.sampleRate<=24000?0.75:source.sampleRate>=88200?1.15:1,base=content==="speech"?channels===1?48000:64000:content==="music"?channels===1?96000:192000:channels===1?64000:128000;return Math.max(32000,Math.min(source.bitrate??1/0,Math.round(base*rate*(format==="opus"?0.8:format==="mp3"?1.2:1)/1000)*1000))}export class AudioBuilder{source;inspected;formats=["opus","aac","mp3"];contentType="general";targetLoudness=-16;capabilities={encoder:!1,codecs:[]};constructor(source){this.source=source}profile(value){validate(value);this.inspected=value;return this}output(value){if(!value.length)throw TypeError("Audio formats are required");this.formats=[...new Set(value)];return this}content(value){this.contentType=value;return this}loudness(value){if(!Number.isFinite(value)||value<-70||value>0)throw TypeError("Audio loudness must be between -70 and 0 LUFS");this.targetLoudness=value;return this}runtime(value){this.capabilities=value;return this}generate(){if(!this.inspected)throw Error("Audio inspection is required; pass its profile with .profile()");const profile=this.inspected,outputs=this.formats.map((format)=>{const info=details[format],copy=profile.codec===format&&(profile.container===info.container||profile.container===info.extension),available=copy||this.capabilities.encoder&&this.capabilities.codecs.includes(format);return{format,...info,bitrate:recommendAudioBitrate(format,profile,this.contentType),action:copy?"copy":"transcode",available,reason:available?void 0:`Native ${format} encoding is unavailable`}});return{source:this.source,profile,outputs,loudness:this.targetLoudness}}async process(options={}){return processAudioPlan(this.generate(),options)}}export function audio(source){return new AudioBuilder(source)}export async function processAudioPlan(plan,options={}){assertAudioPlanExecutable(plan);const{generateAudioDerivatives}=await import("@ts-audio/core/native-transcode");return generateAudioDerivatives(plan.source,{source:plan.profile,outputs:plan.outputs},options)}export function assertAudioPlanExecutable(plan){const missing=plan.outputs.filter((value)=>!value.available);if(missing.length)throw Error(missing.map((value)=>`${value.format}: ${value.reason}`).join("; "))}function q(accept,mime){const[type="",subtype=""]=(mime.split(";",1)[0]??"").split("/");let best=0;for(const item of accept.split(",")){const[range="*/*",...params]=item.trim().toLowerCase().split(";").map((value)=>value.trim()),[a="*",b="*"]=range.split("/");if((a==="*"||a===type)&&(b==="*"||b===subtype)){const raw=params.find((value)=>value.startsWith("q="));best=Math.max(best,raw?Number.parseFloat(raw.slice(2))||0:1)}}return best}export function negotiateAudioOutput(outputs,accept="*/*"){return outputs.filter((value)=>value.available).map((output,index)=>({output,index,q:q(accept||"*/*",output.mimeType)})).filter((value)=>value.q>0).sort((a,b)=>b.q-a.q||a.index-b.index)[0]?.output}export function createWaveform(channels,sampleRate,samples=1000,precision=4){if(!channels.length||sampleRate<=0)throw TypeError("Waveform requires channels and a positive sample rate");const frames=channels[0].length;if(channels.some((channel)=>channel.length!==frames))throw TypeError("Waveform channels must have equal lengths");const size=Math.max(1,Math.ceil(frames/Math.max(1,Math.min(frames||1,Math.floor(samples)))));let absolute=0;const raw=channels.map((channel)=>{const peaks=[];for(let start=0;start<frames;start+=size){let min=1,max=-1;for(let index=start;index<Math.min(frames,start+size);index++){const sample=channel[index]??0,value=Number.isFinite(sample)?Math.max(-1,Math.min(1,sample)):0;min=Math.min(min,value);max=Math.max(max,value);absolute=Math.max(absolute,Math.abs(value))}peaks.push([min,max])}return peaks}),divisor=absolute||1,round=(value)=>Number((value/divisor).toFixed(Math.max(0,Math.min(6,precision))));return{version:1,channels:channels.length,sampleRate,duration:frames/sampleRate,samplesPerPeak:size,peaks:raw.map((channel)=>channel.flatMap(([min,max])=>[round(min),round(max)]))}}function time(seconds){const ms=Math.round(seconds*1000);return`${String(Math.floor(ms/3600000)).padStart(2,"0")}:${String(Math.floor(ms%3600000/60000)).padStart(2,"0")}:${String(Math.floor(ms%60000/1000)).padStart(2,"0")}.${String(ms%1000).padStart(3,"0")}`}export function normalizeTranscript(language,input){if(!language.trim())throw TypeError("Transcript language is required");let end=0;const segments=input.map((item,index)=>{if(item.startTime<end||item.startTime<0||item.endTime<=item.startTime||!item.text.trim())throw TypeError(`Invalid transcript segment ${index}`);if(item.confidence!==void 0&&(item.confidence<0||item.confidence>1))throw TypeError(`Invalid transcript confidence ${index}`);end=item.endTime;return{...item,text:item.text.trim(),id:index+1}}),lines=["WEBVTT",""];for(const item of segments)lines.push(String(item.id),`${time(item.startTime)} --> ${time(item.endTime)}`,item.speaker?`<v ${item.speaker}>${item.text}`:item.text,"");return{language:language.trim().toLowerCase(),segments,vtt:lines.join(`
|
|
2
|
+
`)}}export function audioResponseHeaders(bytes,etag,contentType){return{"Accept-Ranges":"bytes","Content-Length":String(bytes),"Content-Type":contentType,"Cache-Control":"public, max-age=31536000, immutable",ETag:`"${etag}"`,Vary:"Accept"}}export function signAudioAsset(path,expires,secret){return createHmac("sha256",secret).update(`${path}
|
|
3
|
+
${expires}`).digest("base64url")}export function verifyAudioAsset(path,expires,signature,secret,now=Date.now()){if(!Number.isInteger(expires)||expires*1000<=now)return!1;const expected=Buffer.from(signAudioAsset(path,expires,secret)),actual=Buffer.from(signature);return expected.length===actual.length&&timingSafeEqual(expected,actual)}export async function createProtectedAudioPlaylist(segments,protection){if(!segments.length)throw TypeError("Audio playlist requires segments");if(protection&&/[\r\n"]/.test(protection.keyUri))throw TypeError("Invalid audio key URI");const files={},lines=["#EXTM3U","#EXT-X-VERSION:7",`#EXT-X-TARGETDURATION:${Math.ceil(Math.max(...segments.map((item)=>item.duration)))}`,"#EXT-X-PLAYLIST-TYPE:VOD"];for(const[index,segment]of segments.entries()){if(!Number.isFinite(segment.duration)||segment.duration<=0||/[\r\n"]/.test(segment.uri))throw TypeError(`Invalid audio segment ${index}`);let data=segment.data;if(protection){if(protection.key.byteLength!==16)throw TypeError("Audio HLS AES-128 key must contain 16 bytes");const iv=new Uint8Array(16);new DataView(iv.buffer).setBigUint64(8,BigInt(index));const key=Uint8Array.from(protection.key),input=Uint8Array.from(data),cryptoKey=await crypto.subtle.importKey("raw",key.buffer,{name:"AES-CBC"},!1,["encrypt"]);data=new Uint8Array(await crypto.subtle.encrypt({name:"AES-CBC",iv:iv.buffer},cryptoKey,input.buffer));const ivHex=[...iv].map((byte)=>byte.toString(16).padStart(2,"0")).join("");lines.push(`#EXT-X-KEY:METHOD=AES-128,URI="${protection.keyUri}",IV=0x${ivHex}`)}lines.push(`#EXTINF:${segment.duration.toFixed(6)},`,segment.uri);files[segment.uri]=data}lines.push("#EXT-X-ENDLIST","");return{playlist:lines.join(`
|
|
4
|
+
`),files,encrypted:!!protection}}
|