@@ -21,145 +21,57 @@ const activeWorkletNodes = new Set<AudioWorkletNode>()
|
||||
const listeners = new Set<(state: State) => void>()
|
||||
|
||||
export interface State {
|
||||
currentTime: number
|
||||
error: string
|
||||
isInitializing: boolean
|
||||
isReady: boolean
|
||||
sampleRate: number
|
||||
state: AudioContextState
|
||||
error: string
|
||||
isInitializing: boolean
|
||||
workletLoaded: boolean
|
||||
currentTime: number
|
||||
state: AudioContextState
|
||||
}
|
||||
|
||||
export interface WorkletOptions {
|
||||
bufferSize?: number
|
||||
channels?: number
|
||||
converterType?: number
|
||||
inputSampleRate?: number
|
||||
outputSampleRate?: number
|
||||
channels?: number
|
||||
converterType?: number
|
||||
bufferSize?: number
|
||||
}
|
||||
|
||||
export async function cleanupAudioContext() {
|
||||
// Disconnect all active nodes
|
||||
activeSources.forEach(source => source.disconnect())
|
||||
activeGainNodes.forEach(gainNode => gainNode.disconnect())
|
||||
activeAnalyzers.forEach(analyser => analyser.disconnect())
|
||||
activeWorkletNodes.forEach(worklet => worklet.disconnect())
|
||||
|
||||
// Clear sets
|
||||
activeSources.clear()
|
||||
activeGainNodes.clear()
|
||||
activeAnalyzers.clear()
|
||||
activeWorkletNodes.clear()
|
||||
|
||||
// Close context
|
||||
if (context && context.state !== 'closed') {
|
||||
await context.close()
|
||||
}
|
||||
|
||||
context = undefined
|
||||
isReady = false
|
||||
workletLoaded = false
|
||||
error = ''
|
||||
notifyListeners()
|
||||
}
|
||||
|
||||
export function createAudioAnalyser(options?: Partial<{
|
||||
fftSize: number
|
||||
maxDecibels: number
|
||||
minDecibels: number
|
||||
smoothingTimeConstant: number
|
||||
}>): AnalyserNode {
|
||||
if (!context || !isReady) {
|
||||
throw new Error('AudioContext not initialized')
|
||||
}
|
||||
|
||||
const analyser = context.createAnalyser()
|
||||
|
||||
if (options?.fftSize)
|
||||
analyser.fftSize = options.fftSize
|
||||
if (options?.smoothingTimeConstant !== undefined) {
|
||||
analyser.smoothingTimeConstant = options.smoothingTimeConstant
|
||||
}
|
||||
if (options?.minDecibels !== undefined)
|
||||
analyser.minDecibels = options.minDecibels
|
||||
if (options?.maxDecibels !== undefined)
|
||||
analyser.maxDecibels = options.maxDecibels
|
||||
|
||||
activeAnalyzers.add(analyser)
|
||||
return analyser
|
||||
}
|
||||
|
||||
export function createAudioGainNode(initialGain: number = 1): GainNode {
|
||||
if (!context || !isReady) {
|
||||
throw new Error('AudioContext not initialized')
|
||||
}
|
||||
|
||||
const gainNode = context.createGain()
|
||||
gainNode.gain.value = initialGain
|
||||
activeGainNodes.add(gainNode)
|
||||
return gainNode
|
||||
}
|
||||
|
||||
export function createAudioSource(mediaStream: MediaStream): MediaStreamAudioSourceNode {
|
||||
if (!context || !isReady) {
|
||||
throw new Error('AudioContext not initialized')
|
||||
}
|
||||
|
||||
const source = context.createMediaStreamSource(mediaStream)
|
||||
activeSources.add(source)
|
||||
return source
|
||||
}
|
||||
|
||||
export function createResamplingWorkletNode(
|
||||
inputNode: AudioNode,
|
||||
options: WorkletOptions = {},
|
||||
): AudioWorkletNode {
|
||||
if (!context || !isReady || !workletLoaded) {
|
||||
throw new Error('AudioContext or worklets not ready')
|
||||
}
|
||||
|
||||
const workletOptions = {
|
||||
bufferSize: 4096,
|
||||
channels: 1,
|
||||
converterType: 2, // SRC_SINC_MEDIUM_QUALITY
|
||||
inputSampleRate: sampleRate,
|
||||
outputSampleRate: 16000,
|
||||
...options,
|
||||
}
|
||||
|
||||
const workletNode = new AudioWorkletNode(context, 'resampling-processor', {
|
||||
channelCount: workletOptions.channels,
|
||||
numberOfInputs: 1,
|
||||
numberOfOutputs: 1,
|
||||
processorOptions: workletOptions,
|
||||
})
|
||||
|
||||
// Connect input to worklet
|
||||
inputNode.connect(workletNode)
|
||||
|
||||
activeWorkletNodes.add(workletNode)
|
||||
|
||||
return workletNode
|
||||
}
|
||||
|
||||
export function getAudioContext(): AudioContext | undefined {
|
||||
return context
|
||||
}
|
||||
|
||||
export function getAudioContextState(): State {
|
||||
return {
|
||||
currentTime: context?.currentTime ?? 0,
|
||||
error,
|
||||
isInitializing,
|
||||
function notifyListeners() {
|
||||
const state: State = {
|
||||
isReady,
|
||||
sampleRate,
|
||||
state: context?.state ?? 'closed',
|
||||
error,
|
||||
isInitializing,
|
||||
workletLoaded,
|
||||
currentTime: context?.currentTime ?? 0,
|
||||
state: context?.state ?? 'closed',
|
||||
}
|
||||
listeners.forEach((listener) => {
|
||||
try {
|
||||
listener(state)
|
||||
}
|
||||
catch (err) {
|
||||
console.error('AudioContext state listener error:', err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function getCurrentTime(): number {
|
||||
return context?.currentTime ?? 0
|
||||
async function loadWorklets() {
|
||||
if (!context || workletLoaded)
|
||||
return
|
||||
|
||||
try {
|
||||
await context.audioWorklet.addModule(ProcessorWorkletURL)
|
||||
await context.audioWorklet.addModule(LibsamplerateWorkletURL)
|
||||
|
||||
workletLoaded = true
|
||||
}
|
||||
catch (err) {
|
||||
console.error('Failed to load AudioWorklets:', err)
|
||||
throw new Error(`Worklet loading failed: ${err}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function initializeAudioContext(requestedSampleRate: number = 48000): Promise<AudioContext> {
|
||||
@@ -231,18 +143,51 @@ export async function initializeAudioContext(requestedSampleRate: number = 48000
|
||||
}
|
||||
}
|
||||
|
||||
export function isAudioContextReady(): boolean {
|
||||
return isReady
|
||||
export function createAudioSource(mediaStream: MediaStream): MediaStreamAudioSourceNode {
|
||||
if (!context || !isReady) {
|
||||
throw new Error('AudioContext not initialized')
|
||||
}
|
||||
|
||||
const source = context.createMediaStreamSource(mediaStream)
|
||||
activeSources.add(source)
|
||||
return source
|
||||
}
|
||||
|
||||
export function removeAudioAnalyser(analyser: AnalyserNode) {
|
||||
analyser.disconnect()
|
||||
activeAnalyzers.delete(analyser)
|
||||
export function createAudioAnalyser(options?: Partial<{
|
||||
fftSize: number
|
||||
smoothingTimeConstant: number
|
||||
minDecibels: number
|
||||
maxDecibels: number
|
||||
}>): AnalyserNode {
|
||||
if (!context || !isReady) {
|
||||
throw new Error('AudioContext not initialized')
|
||||
}
|
||||
|
||||
const analyser = context.createAnalyser()
|
||||
|
||||
if (options?.fftSize)
|
||||
analyser.fftSize = options.fftSize
|
||||
if (options?.smoothingTimeConstant !== undefined) {
|
||||
analyser.smoothingTimeConstant = options.smoothingTimeConstant
|
||||
}
|
||||
if (options?.minDecibels !== undefined)
|
||||
analyser.minDecibels = options.minDecibels
|
||||
if (options?.maxDecibels !== undefined)
|
||||
analyser.maxDecibels = options.maxDecibels
|
||||
|
||||
activeAnalyzers.add(analyser)
|
||||
return analyser
|
||||
}
|
||||
|
||||
export function removeAudioGainNode(gainNode: GainNode) {
|
||||
gainNode.disconnect()
|
||||
activeGainNodes.delete(gainNode)
|
||||
export function createAudioGainNode(initialGain: number = 1): GainNode {
|
||||
if (!context || !isReady) {
|
||||
throw new Error('AudioContext not initialized')
|
||||
}
|
||||
|
||||
const gainNode = context.createGain()
|
||||
gainNode.gain.value = initialGain
|
||||
activeGainNodes.add(gainNode)
|
||||
return gainNode
|
||||
}
|
||||
|
||||
export function removeAudioSource(source: MediaStreamAudioSourceNode) {
|
||||
@@ -250,22 +195,14 @@ export function removeAudioSource(source: MediaStreamAudioSourceNode) {
|
||||
activeSources.delete(source)
|
||||
}
|
||||
|
||||
export function removeWorkletNode(node: AudioWorkletNode) {
|
||||
node.disconnect()
|
||||
activeWorkletNodes.delete(node)
|
||||
export function removeAudioGainNode(gainNode: GainNode) {
|
||||
gainNode.disconnect()
|
||||
activeGainNodes.delete(gainNode)
|
||||
}
|
||||
|
||||
export async function resumeAudioContext() {
|
||||
if (context && context.state === 'suspended') {
|
||||
await context.resume()
|
||||
notifyListeners()
|
||||
}
|
||||
}
|
||||
|
||||
// Event subscription
|
||||
export function subscribeToAudioContext(listener: (state: State) => void): () => void {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
export function removeAudioAnalyser(analyser: AnalyserNode) {
|
||||
analyser.disconnect()
|
||||
activeAnalyzers.delete(analyser)
|
||||
}
|
||||
|
||||
export async function suspendAudioContext() {
|
||||
@@ -275,40 +212,103 @@ export async function suspendAudioContext() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWorklets() {
|
||||
if (!context || workletLoaded)
|
||||
return
|
||||
|
||||
try {
|
||||
await context.audioWorklet.addModule(ProcessorWorkletURL)
|
||||
await context.audioWorklet.addModule(LibsamplerateWorkletURL)
|
||||
|
||||
workletLoaded = true
|
||||
}
|
||||
catch (err) {
|
||||
console.error('Failed to load AudioWorklets:', err)
|
||||
throw new Error(`Worklet loading failed: ${err}`)
|
||||
export async function resumeAudioContext() {
|
||||
if (context && context.state === 'suspended') {
|
||||
await context.resume()
|
||||
notifyListeners()
|
||||
}
|
||||
}
|
||||
|
||||
function notifyListeners() {
|
||||
const state: State = {
|
||||
currentTime: context?.currentTime ?? 0,
|
||||
error,
|
||||
isInitializing,
|
||||
export function createResamplingWorkletNode(
|
||||
inputNode: AudioNode,
|
||||
options: WorkletOptions = {},
|
||||
): AudioWorkletNode {
|
||||
if (!context || !isReady || !workletLoaded) {
|
||||
throw new Error('AudioContext or worklets not ready')
|
||||
}
|
||||
|
||||
const workletOptions = {
|
||||
inputSampleRate: sampleRate,
|
||||
outputSampleRate: 16000,
|
||||
channels: 1,
|
||||
converterType: 2, // SRC_SINC_MEDIUM_QUALITY
|
||||
bufferSize: 4096,
|
||||
...options,
|
||||
}
|
||||
|
||||
const workletNode = new AudioWorkletNode(context, 'resampling-processor', {
|
||||
numberOfInputs: 1,
|
||||
numberOfOutputs: 1,
|
||||
channelCount: workletOptions.channels,
|
||||
processorOptions: workletOptions,
|
||||
})
|
||||
|
||||
// Connect input to worklet
|
||||
inputNode.connect(workletNode)
|
||||
|
||||
activeWorkletNodes.add(workletNode)
|
||||
|
||||
return workletNode
|
||||
}
|
||||
|
||||
export function removeWorkletNode(node: AudioWorkletNode) {
|
||||
node.disconnect()
|
||||
activeWorkletNodes.delete(node)
|
||||
}
|
||||
|
||||
export async function cleanupAudioContext() {
|
||||
// Disconnect all active nodes
|
||||
activeSources.forEach(source => source.disconnect())
|
||||
activeGainNodes.forEach(gainNode => gainNode.disconnect())
|
||||
activeAnalyzers.forEach(analyser => analyser.disconnect())
|
||||
activeWorkletNodes.forEach(worklet => worklet.disconnect())
|
||||
|
||||
// Clear sets
|
||||
activeSources.clear()
|
||||
activeGainNodes.clear()
|
||||
activeAnalyzers.clear()
|
||||
activeWorkletNodes.clear()
|
||||
|
||||
// Close context
|
||||
if (context && context.state !== 'closed') {
|
||||
await context.close()
|
||||
}
|
||||
|
||||
context = undefined
|
||||
isReady = false
|
||||
workletLoaded = false
|
||||
error = ''
|
||||
notifyListeners()
|
||||
}
|
||||
|
||||
export function getAudioContextState(): State {
|
||||
return {
|
||||
isReady,
|
||||
sampleRate,
|
||||
state: context?.state ?? 'closed',
|
||||
error,
|
||||
isInitializing,
|
||||
workletLoaded,
|
||||
currentTime: context?.currentTime ?? 0,
|
||||
state: context?.state ?? 'closed',
|
||||
}
|
||||
listeners.forEach((listener) => {
|
||||
try {
|
||||
listener(state)
|
||||
}
|
||||
catch (err) {
|
||||
console.error('AudioContext state listener error:', err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function getAudioContext(): AudioContext | undefined {
|
||||
return context
|
||||
}
|
||||
|
||||
export function getCurrentTime(): number {
|
||||
return context?.currentTime ?? 0
|
||||
}
|
||||
|
||||
export function isAudioContextReady(): boolean {
|
||||
return isReady
|
||||
}
|
||||
|
||||
// Event subscription
|
||||
export function subscribeToAudioContext(listener: (state: State) => void): () => void {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
}
|
||||
|
||||
// Browser cleanup
|
||||
|
||||
@@ -7,30 +7,30 @@ import { ConverterType, create } from '@alexanderolsen/libsamplerate-js'
|
||||
import { errorMessageFromValue } from '../utils/error-message'
|
||||
|
||||
interface ProcessorOptions {
|
||||
bufferSize: number
|
||||
channels: number
|
||||
converterType: ConverterTypeValue
|
||||
inputSampleRate: number
|
||||
outputSampleRate: number
|
||||
channels: number
|
||||
converterType: ConverterTypeValue
|
||||
bufferSize: number
|
||||
}
|
||||
|
||||
class ResamplingAudioWorkletProcessor extends AudioWorkletProcessor {
|
||||
private bufferSize: number
|
||||
private converter: Awaited<ReturnType<typeof create>> | null = null
|
||||
private inputBuffer: Float32Array[] = []
|
||||
private isInitialized = false
|
||||
private options: ProcessorOptions
|
||||
private inputBuffer: Float32Array[] = []
|
||||
private outputBuffer: Float32Array[] = []
|
||||
private bufferSize: number
|
||||
|
||||
constructor(options: AudioWorkletNodeOptions) {
|
||||
super()
|
||||
|
||||
this.options = {
|
||||
bufferSize: options.processorOptions?.bufferSize || 4096,
|
||||
channels: options.processorOptions?.channels || 1,
|
||||
converterType: options.processorOptions?.converterType || ConverterType.SRC_SINC_MEDIUM_QUALITY,
|
||||
inputSampleRate: options.processorOptions?.inputSampleRate || 44100,
|
||||
outputSampleRate: options.processorOptions?.outputSampleRate || 16000,
|
||||
channels: options.processorOptions?.channels || 1,
|
||||
converterType: options.processorOptions?.converterType || ConverterType.SRC_SINC_MEDIUM_QUALITY,
|
||||
bufferSize: options.processorOptions?.bufferSize || 4096,
|
||||
}
|
||||
|
||||
this.bufferSize = this.options.bufferSize
|
||||
@@ -51,6 +51,47 @@ class ResamplingAudioWorkletProcessor extends AudioWorkletProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
private async initializeConverter() {
|
||||
try {
|
||||
this.converter = await create(
|
||||
this.options.channels,
|
||||
this.options.inputSampleRate,
|
||||
this.options.outputSampleRate,
|
||||
{
|
||||
converterType: this.options.converterType,
|
||||
},
|
||||
)
|
||||
this.isInitialized = true
|
||||
this.port.postMessage({ type: 'initialized', success: true })
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to initialize sample rate converter:', error)
|
||||
|
||||
this.port.postMessage({
|
||||
type: 'initialized',
|
||||
success: false,
|
||||
error: errorMessageFromValue(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private async updateOptions(newOptions: Partial<ProcessorOptions>) {
|
||||
const needsReinitialize
|
||||
= newOptions.inputSampleRate !== this.options.inputSampleRate
|
||||
|| newOptions.outputSampleRate !== this.options.outputSampleRate
|
||||
|| newOptions.channels !== this.options.channels
|
||||
|| newOptions.converterType !== this.options.converterType
|
||||
|
||||
Object.assign(this.options, newOptions)
|
||||
|
||||
if (needsReinitialize && this.converter) {
|
||||
this.converter.destroy()
|
||||
this.converter = null
|
||||
this.isInitialized = false
|
||||
await this.initializeConverter()
|
||||
}
|
||||
}
|
||||
|
||||
process(inputs: Float32Array[][], outputs: Float32Array[][]): boolean {
|
||||
const input = inputs[0]
|
||||
const output = outputs[0]
|
||||
@@ -76,12 +117,12 @@ class ResamplingAudioWorkletProcessor extends AudioWorkletProcessor {
|
||||
|
||||
// Send resampled data to main thread
|
||||
this.port.postMessage({
|
||||
type: 'audioData',
|
||||
channel,
|
||||
data: resampledData,
|
||||
originalSampleRate: this.options.inputSampleRate,
|
||||
outputSampleRate: this.options.outputSampleRate,
|
||||
timestamp: currentTime,
|
||||
type: 'audioData',
|
||||
})
|
||||
|
||||
// Copy to output (you might want to buffer this properly for different sample rates)
|
||||
@@ -102,8 +143,8 @@ class ResamplingAudioWorkletProcessor extends AudioWorkletProcessor {
|
||||
console.error('Resampling error in worklet:', error)
|
||||
|
||||
this.port.postMessage({
|
||||
error: errorMessageFromValue(error),
|
||||
type: 'error',
|
||||
error: errorMessageFromValue(error),
|
||||
})
|
||||
|
||||
// Pass through original data on error
|
||||
@@ -116,47 +157,6 @@ class ResamplingAudioWorkletProcessor extends AudioWorkletProcessor {
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private async initializeConverter() {
|
||||
try {
|
||||
this.converter = await create(
|
||||
this.options.channels,
|
||||
this.options.inputSampleRate,
|
||||
this.options.outputSampleRate,
|
||||
{
|
||||
converterType: this.options.converterType,
|
||||
},
|
||||
)
|
||||
this.isInitialized = true
|
||||
this.port.postMessage({ success: true, type: 'initialized' })
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to initialize sample rate converter:', error)
|
||||
|
||||
this.port.postMessage({
|
||||
error: errorMessageFromValue(error),
|
||||
success: false,
|
||||
type: 'initialized',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private async updateOptions(newOptions: Partial<ProcessorOptions>) {
|
||||
const needsReinitialize
|
||||
= newOptions.inputSampleRate !== this.options.inputSampleRate
|
||||
|| newOptions.outputSampleRate !== this.options.outputSampleRate
|
||||
|| newOptions.channels !== this.options.channels
|
||||
|| newOptions.converterType !== this.options.converterType
|
||||
|
||||
Object.assign(this.options, newOptions)
|
||||
|
||||
if (needsReinitialize && this.converter) {
|
||||
this.converter.destroy()
|
||||
this.converter = null
|
||||
this.isInitialized = false
|
||||
await this.initializeConverter()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor('resampling-processor', ResamplingAudioWorkletProcessor)
|
||||
|
||||
@@ -1,73 +1,9 @@
|
||||
import { encodeBase64 } from '@moeru/std/base64'
|
||||
|
||||
/**
|
||||
* Converts little-endian signed PCM16 bytes to normalized Float32 PCM samples.
|
||||
*
|
||||
* @example
|
||||
* toFloat32FromPCM16(new Uint8Array([0, 128, 0, 0, 255, 127]))
|
||||
* // => Float32Array([-1, 0, 0.999969482421875])
|
||||
*/
|
||||
export function toFloat32FromPCM16(pcmBytes: Uint8Array): Float32Array<ArrayBuffer> {
|
||||
if (pcmBytes.byteLength % Int16Array.BYTES_PER_ELEMENT !== 0)
|
||||
throw new TypeError('PCM16 input must contain complete 16-bit samples.')
|
||||
|
||||
const dataView = new DataView(pcmBytes.buffer, pcmBytes.byteOffset, pcmBytes.byteLength)
|
||||
const output = new Float32Array(pcmBytes.byteLength / Int16Array.BYTES_PER_ELEMENT)
|
||||
|
||||
for (let i = 0; i < output.length; i++)
|
||||
output[i] = dataView.getInt16(i * Int16Array.BYTES_PER_ELEMENT, true) / 0x8000
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts normalized Float32 PCM samples to little-endian signed PCM16 bytes.
|
||||
* Values outside the normalized range are clamped.
|
||||
*
|
||||
* @example
|
||||
* toPCM16FromFloat32(new Float32Array([-1, 0, 1]))
|
||||
* // => Uint8Array([0, 128, 0, 0, 255, 127])
|
||||
*/
|
||||
export function toPCM16FromFloat32(samples: Float32Array): Uint8Array<ArrayBuffer> {
|
||||
const output = new Uint8Array(samples.length * Int16Array.BYTES_PER_ELEMENT)
|
||||
const dataView = new DataView(output.buffer)
|
||||
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
const sample = Math.max(-1, Math.min(1, samples[i]))
|
||||
const value = sample < 0 ? sample * 0x8000 : sample * 0x7FFF
|
||||
dataView.setInt16(i * Int16Array.BYTES_PER_ELEMENT, value, true)
|
||||
function writeString(dataView: DataView, offset: number, string: string) {
|
||||
for (let i = 0; i < string.length; i++) {
|
||||
dataView.setUint8(offset + i, string.charCodeAt(i))
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes Float32 samples as a WAV file.
|
||||
*
|
||||
* @example
|
||||
* toWav(float32Samples.buffer, 24000)
|
||||
* // => WAV data with converted PCM16 samples
|
||||
*/
|
||||
export function toWav(buffer: ArrayBufferLike, sampleRate: number, channel = 1): ArrayBuffer {
|
||||
const samples = new Float32Array(buffer)
|
||||
return toWavFromPCM16(toPCM16FromFloat32(samples), sampleRate, channel)
|
||||
}
|
||||
|
||||
export function toWAVBase64(buffer: ArrayBufferLike, sampleRate: number) {
|
||||
return encodeBase64(toWav(buffer, sampleRate))
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps raw signed 16-bit PCM samples in a WAV file.
|
||||
*
|
||||
* @example
|
||||
* toWavFromPCM16(pcmBytes, 24000)
|
||||
* // => WAV data with the original PCM16 bytes
|
||||
*/
|
||||
export function toWavFromPCM16(pcmBytes: Uint8Array, sampleRate: number, channel = 1): ArrayBuffer {
|
||||
const arrayBuffer = createWavBuffer(pcmBytes.byteLength, sampleRate, channel)
|
||||
new Uint8Array(arrayBuffer, 44).set(pcmBytes)
|
||||
return arrayBuffer
|
||||
}
|
||||
|
||||
function createWavBuffer(dataSize: number, sampleRate: number, channel: number): ArrayBuffer {
|
||||
@@ -96,8 +32,72 @@ function createWavBuffer(dataSize: number, sampleRate: number, channel: number):
|
||||
return arrayBuffer
|
||||
}
|
||||
|
||||
function writeString(dataView: DataView, offset: number, string: string) {
|
||||
for (let i = 0; i < string.length; i++) {
|
||||
dataView.setUint8(offset + i, string.charCodeAt(i))
|
||||
/**
|
||||
* Converts normalized Float32 PCM samples to little-endian signed PCM16 bytes.
|
||||
* Values outside the normalized range are clamped.
|
||||
*
|
||||
* @example
|
||||
* toPCM16FromFloat32(new Float32Array([-1, 0, 1]))
|
||||
* // => Uint8Array([0, 128, 0, 0, 255, 127])
|
||||
*/
|
||||
export function toPCM16FromFloat32(samples: Float32Array): Uint8Array<ArrayBuffer> {
|
||||
const output = new Uint8Array(samples.length * Int16Array.BYTES_PER_ELEMENT)
|
||||
const dataView = new DataView(output.buffer)
|
||||
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
const sample = Math.max(-1, Math.min(1, samples[i]))
|
||||
const value = sample < 0 ? sample * 0x8000 : sample * 0x7FFF
|
||||
dataView.setInt16(i * Int16Array.BYTES_PER_ELEMENT, value, true)
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts little-endian signed PCM16 bytes to normalized Float32 PCM samples.
|
||||
*
|
||||
* @example
|
||||
* toFloat32FromPCM16(new Uint8Array([0, 128, 0, 0, 255, 127]))
|
||||
* // => Float32Array([-1, 0, 0.999969482421875])
|
||||
*/
|
||||
export function toFloat32FromPCM16(pcmBytes: Uint8Array): Float32Array<ArrayBuffer> {
|
||||
if (pcmBytes.byteLength % Int16Array.BYTES_PER_ELEMENT !== 0)
|
||||
throw new TypeError('PCM16 input must contain complete 16-bit samples.')
|
||||
|
||||
const dataView = new DataView(pcmBytes.buffer, pcmBytes.byteOffset, pcmBytes.byteLength)
|
||||
const output = new Float32Array(pcmBytes.byteLength / Int16Array.BYTES_PER_ELEMENT)
|
||||
|
||||
for (let i = 0; i < output.length; i++)
|
||||
output[i] = dataView.getInt16(i * Int16Array.BYTES_PER_ELEMENT, true) / 0x8000
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes Float32 samples as a WAV file.
|
||||
*
|
||||
* @example
|
||||
* toWav(float32Samples.buffer, 24000)
|
||||
* // => WAV data with converted PCM16 samples
|
||||
*/
|
||||
export function toWav(buffer: ArrayBufferLike, sampleRate: number, channel = 1): ArrayBuffer {
|
||||
const samples = new Float32Array(buffer)
|
||||
return toWavFromPCM16(toPCM16FromFloat32(samples), sampleRate, channel)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps raw signed 16-bit PCM samples in a WAV file.
|
||||
*
|
||||
* @example
|
||||
* toWavFromPCM16(pcmBytes, 24000)
|
||||
* // => WAV data with the original PCM16 bytes
|
||||
*/
|
||||
export function toWavFromPCM16(pcmBytes: Uint8Array, sampleRate: number, channel = 1): ArrayBuffer {
|
||||
const arrayBuffer = createWavBuffer(pcmBytes.byteLength, sampleRate, channel)
|
||||
new Uint8Array(arrayBuffer, 44).set(pcmBytes)
|
||||
return arrayBuffer
|
||||
}
|
||||
|
||||
export function toWAVBase64(buffer: ArrayBufferLike, sampleRate: number) {
|
||||
return encodeBase64(toWav(buffer, sampleRate))
|
||||
}
|
||||
|
||||
@@ -2,14 +2,14 @@ import { defineConfig } from 'tsdown'
|
||||
|
||||
export default defineConfig({
|
||||
entry: {
|
||||
'index': 'src/index.ts',
|
||||
'audio-context/index': 'src/audio-context/index.ts',
|
||||
'audio-context/processor.worklet': 'src/audio-context/processor.worklet.ts',
|
||||
'encoding/index': 'src/encoding/index.ts',
|
||||
'index': 'src/index.ts',
|
||||
},
|
||||
unbundle: true,
|
||||
external: [
|
||||
'@alexanderolsen/libsamplerate-js/dist/libsamplerate.worklet.js?worker&url',
|
||||
'./processor.worklet?worker&url',
|
||||
],
|
||||
unbundle: true,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user