@stacksjs/audio 0.75.283 → 0.75.287

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 CHANGED
@@ -68,3 +68,7 @@ Native HLS AES-128 uses Web Crypto. Proprietary DRM metadata is accepted only fo
68
68
  The library player includes play, seek, time, mute, volume, speed, settings, waveform seeking, AirPlay, and Remote Playback. Native audio controls remain available before custom elements load and when JavaScript is unavailable.
69
69
 
70
70
  `drm` accepts the same Widevine, PlayReady and FairPlay descriptors as `<Video>` and applies them to DASH sources. `artwork` supplies a cover image; `crossorigin` controls media CORS.
71
+
72
+ ## Bounded speech uploads
73
+
74
+ `prepareSpeechAudio(bytes, { maxBytes, maxDurationSeconds, timeoutMs, signal })` decodes an untrusted upload to mono 16 kHz PCM WAV, measures the decoded samples and refuses audio beyond the duration limit. Defaults are 12 MiB input, five minutes and a 30-second decoder deadline. Install FFmpeg in the server runtime; an absent decoder is an explicit error. The decoder reads and writes pipes, disables external input protocols, bounds both output streams, and keeps no temporary recording files. `decoderPath` is a deployment option, never HTTP input.
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export type { SpeechAudioOptions, PreparedSpeechAudio } from './speech-input';
1
2
  export declare function recommendAudioBitrate(format: AudioFormat, source: AudioProfile, content: AudioContent): number;
2
3
  export declare function audio(source: string): AudioBuilder;
3
4
  export declare function processAudioPlan(plan: AudioPlan, options?: AudioProcessOptions): Promise<ProcessedAudioDerivative[]>;
@@ -33,3 +34,4 @@ export declare class AudioBuilder {
33
34
  generate(): AudioPlan;
34
35
  process(options?: AudioProcessOptions): Promise<ProcessedAudioDerivative[]>;
35
36
  }
37
+ export { prepareSpeechAudio } from './speech-input';
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
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(`
1
+ import{createHmac,timingSafeEqual}from"node:crypto";export{prepareSpeechAudio}from"./speech-input";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
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
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
4
  `),files,encrypted:!!protection}}
@@ -0,0 +1,14 @@
1
+ export declare function prepareSpeechAudio(input: Uint8Array, options?: SpeechAudioOptions): Promise<PreparedSpeechAudio>;
2
+ /** Decode untrusted voice uploads before billing or sending them to a speech provider. */
3
+ export declare interface SpeechAudioOptions {
4
+ maxBytes?: number
5
+ maxDurationSeconds?: number
6
+ timeoutMs?: number
7
+ signal?: AbortSignal
8
+ decoderPath?: string
9
+ }
10
+ export declare interface PreparedSpeechAudio {
11
+ audio: Uint8Array
12
+ mimeType: 'audio/wav'
13
+ durationSeconds: number
14
+ }
@@ -0,0 +1 @@
1
+ export async function prepareSpeechAudio(input,options={}){const maxBytes=options.maxBytes??12582912,maxDuration=options.maxDurationSeconds??300,timeout=options.timeoutMs??30000;if(![maxBytes,maxDuration,timeout].every((value)=>Number.isSafeInteger(value)&&value>0)||maxDuration>3600)throw TypeError("Invalid speech audio limits");if(!input.length||input.length>maxBytes)throw Error("Audio size exceeds the upload limit or is empty");options.signal?.throwIfAborted();const executable=options.decoderPath||Bun.which("ffmpeg");if(!executable)throw Error("Speech audio preparation needs FFmpeg installed on the server");const child=Bun.spawn([executable,"-nostdin","-hide_banner","-v","error","-threads","1","-protocol_whitelist","cache,pipe","-read_ahead_limit",String(Math.min(maxBytes,2147483647)),"-i","cache:pipe:0","-map","0:a:0","-vn","-t",String(maxDuration+1),"-ac","1","-ar","16000","-c:a","pcm_s16le","-f","wav","pipe:1"],{stdin:new Blob([new Uint8Array(input)]),stdout:"pipe",stderr:"pipe"});let timedOut=!1;const stop=()=>{if(child.exitCode===null)child.kill("SIGKILL")},timer=setTimeout(()=>{timedOut=!0;stop()},timeout);options.signal?.addEventListener("abort",stop,{once:!0});async function bounded(stream,limit){const reader=stream.getReader(),chunks=[];let size=0;try{while(!0){const item=await reader.read();if(item.done)break;size+=item.value.length;if(size>limit){stop();throw Error("Audio decoder exceeded its output limit")}chunks.push(item.value)}}finally{reader.releaseLock()}const bytes=new Uint8Array(size);let offset=0;for(const chunk of chunks){bytes.set(chunk,offset);offset+=chunk.length}return bytes}try{const[audio,,code]=await Promise.all([bounded(child.stdout,(maxDuration+1)*32000+4096),bounded(child.stderr,16384),child.exited]);options.signal?.throwIfAborted();if(timedOut)throw Error("Audio decoder timed out");if(code!==0||audio.length<44)throw Error("The uploaded audio could not be decoded");const view=new DataView(audio.buffer,audio.byteOffset,audio.byteLength);for(let offset=12;offset+8<=audio.length;){const size=view.getUint32(offset+4,!0);if(String.fromCharCode(...audio.subarray(offset,offset+4))==="data"){const durationSeconds=(audio.length-offset-8)/32000;if(durationSeconds<=0||durationSeconds>maxDuration)throw Error("Audio duration exceeds the recording limit or is empty");return{audio,mimeType:"audio/wav",durationSeconds}}offset+=8+size+size%2}throw Error("Audio decoder returned an invalid WAV container")}finally{clearTimeout(timer);options.signal?.removeEventListener("abort",stop);stop()}}
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/audio",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.75.283",
5
+ "version": "0.75.287",
6
6
  "description": "Native audio delivery planning for Stacks.",
7
7
  "author": "Chris Breuer",
8
8
  "license": "MIT",