Skip to content

Commit d6dd3d8

Browse files
committed
feat(audio): execute native delivery plans
1 parent 27dcdac commit d6dd3d8

3 files changed

Lines changed: 33 additions & 4 deletions

File tree

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"name":"@stacksjs/audio","type":"module","sideEffects":false,"version":"0.70.162","description":"Native audio delivery planning for Stacks.","author":"Chris Breuer","license":"MIT","funding":"https://github.com/sponsors/chrisbbreuer","repository":{"type":"git","url":"git+https://github.com/stacksjs/stacks.git","directory":"./storage/framework/core/audio"},"exports":{".":{"types":"./dist/index.d.ts","development":"./src/index.ts","bun":"./dist/index.js","import":"./dist/index.js"}},"module":"dist/index.js","types":"dist/index.d.ts","files":["README.md","dist"],"scripts":{"build":"bun build.ts","typecheck":"bun tsc --noEmit","prepublishOnly":"bun run build"},"dependencies":{"ts-video-player":"^0.1.0"},"devDependencies":{"better-dx":"catalog:"}}
1+
{"name":"@stacksjs/audio","type":"module","sideEffects":false,"version":"0.70.162","description":"Native audio delivery planning for Stacks.","author":"Chris Breuer","license":"MIT","funding":"https://github.com/sponsors/chrisbbreuer","repository":{"type":"git","url":"git+https://github.com/stacksjs/stacks.git","directory":"./storage/framework/core/audio"},"exports":{".":{"types":"./dist/index.d.ts","development":"./src/index.ts","bun":"./dist/index.js","import":"./dist/index.js"}},"module":"dist/index.js","types":"dist/index.d.ts","files":["README.md","dist"],"scripts":{"build":"bun build.ts","typecheck":"bun tsc --noEmit","prepublishOnly":"bun run build"},"dependencies":{"ts-audio":"^0.0.1","ts-video-player":"^0.1.0"},"devDependencies":{"better-dx":"catalog:"}}

storage/framework/core/audio/src/index.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,17 @@ import { createHmac, timingSafeEqual } from 'node:crypto'
22

33
export type AudioFormat = 'opus' | 'aac' | 'mp3'
44
export type AudioContent = 'speech' | 'music' | 'general'
5-
export interface AudioProfile { codec: string, container: string, duration: number, sampleRate: number, channels: number, bitrate?: number }
5+
export type AudioCodec = 'aac' | 'mp3' | 'opus' | 'vorbis' | 'flac' | 'alac' | 'pcm_s16le' | 'pcm_s24le' | 'pcm_s32le' | 'pcm_f32le' | 'pcm_f64le'
6+
export interface AudioProfile { codec: AudioCodec, container: string, duration: number, sampleRate: number, channels: number, bitrate?: number }
67
export interface AudioCapabilities { encoder: boolean, codecs: AudioFormat[] }
7-
export interface AudioOutput { format: AudioFormat, container: 'ogg' | 'm4a' | 'mp3', mimeType: string, extension: 'ogg' | 'm4a' | 'mp3', bitrate: number, action: 'copy' | 'transcode', available: boolean, reason?: string }
8+
export interface AudioOutput { format: AudioFormat, container: 'ogg' | 'aac' | 'mp3', mimeType: 'audio/ogg; codecs=opus' | 'audio/aac' | 'audio/mpeg', extension: 'ogg' | 'aac' | 'mp3', bitrate: number, action: 'copy' | 'transcode', available: boolean, reason?: string }
89
export interface AudioPlan { source: string, profile: AudioProfile, outputs: AudioOutput[], loudness: number }
10+
export interface AudioProcessOptions { batchSize?: number, signal?: AbortSignal }
11+
export interface ProcessedAudioDerivative { output: AudioOutput, bytes: Uint8Array }
912
export interface Waveform { version: 1, channels: number, sampleRate: number, duration: number, samplesPerPeak: number, peaks: number[][] }
1013
export interface TranscriptSegment { startTime: number, endTime: number, text: string, confidence?: number, speaker?: string }
1114
export interface Transcript { language: string, segments: Array<TranscriptSegment & { id: number }>, vtt: string }
12-
const details: Record<AudioFormat, Pick<AudioOutput, 'container' | 'mimeType' | 'extension'>> = { opus: { container: 'ogg', mimeType: 'audio/ogg; codecs=opus', extension: 'ogg' }, aac: { container: 'm4a', mimeType: 'audio/mp4; codecs=mp4a.40.2', extension: 'm4a' }, mp3: { container: 'mp3', mimeType: 'audio/mpeg', extension: 'mp3' } }
15+
const details: Record<AudioFormat, Pick<AudioOutput, 'container' | 'mimeType' | 'extension'>> = { 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' } }
1316
function validate(profile: AudioProfile): void {
1417
for (const [name, value] of Object.entries({ duration: profile.duration, sampleRate: profile.sampleRate, channels: profile.channels })) {
1518
if (!Number.isFinite(value) || value <= 0) throw new TypeError(`Audio ${name} must be positive`)
@@ -34,8 +37,14 @@ export class AudioBuilder {
3437
const profile = this.inspected; const outputs = this.formats.map((format): AudioOutput => { const info = details[format]; const copy = profile.codec === format && (profile.container === info.container || profile.container === info.extension); const 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 ? undefined : `Native ${format} encoding is unavailable` } })
3538
return { source: this.source, profile, outputs, loudness: this.targetLoudness }
3639
}
40+
async process(options: AudioProcessOptions = {}): Promise<ProcessedAudioDerivative[]> { return processAudioPlan(this.generate(), options) }
3741
}
3842
export function audio(source: string): AudioBuilder { return new AudioBuilder(source) }
43+
export async function processAudioPlan(plan: AudioPlan, options: AudioProcessOptions = {}): Promise<ProcessedAudioDerivative[]> {
44+
assertAudioPlanExecutable(plan)
45+
const { generateAudioDerivatives } = await import('ts-audio/native-transcode')
46+
return generateAudioDerivatives(plan.source, { source: plan.profile, outputs: plan.outputs }, options)
47+
}
3948
export function assertAudioPlanExecutable(plan: AudioPlan): void {
4049
const missing = plan.outputs.filter(value => !value.available)
4150
if (missing.length) throw new Error(missing.map(value => `${value.format}: ${value.reason}`).join('; '))
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { expect, mock, test } from 'bun:test'
2+
3+
mock.module('ts-audio/native-transcode', () => ({
4+
generateAudioDerivatives: async (source: string, plan: { source: { codec: string }, outputs: unknown[] }): Promise<unknown[]> => {
5+
expect(source).toBe('episode.flac')
6+
expect(plan.source.codec).toBe('flac')
7+
expect(plan.outputs).toHaveLength(3)
8+
return [{ output: plan.outputs[0], bytes: new Uint8Array([4, 5, 6]) }]
9+
},
10+
}))
11+
12+
const { audio } = await import('../src')
13+
14+
test('processes a generated audio plan through ts-audio', async () => {
15+
const result = await audio('episode.flac')
16+
.profile({ codec: 'flac', container: 'flac', duration: 60, sampleRate: 48_000, channels: 2 })
17+
.runtime({ encoder: true, codecs: ['opus', 'aac', 'mp3'] })
18+
.process()
19+
expect(result[0]?.bytes).toEqual(new Uint8Array([4, 5, 6]))
20+
})

0 commit comments

Comments
 (0)