Compare commits

...
9 changed files with 362 additions and 240 deletions
-10
View File
@@ -2412,11 +2412,6 @@
"count": 2
}
},
"web/app/components/base/voice-input/utils.ts": {
"ts/no-explicit-any": {
"count": 4
}
},
"web/app/components/billing/plan/assets/index.tsx": {
"no-barrel-files/no-barrel-files": {
"count": 4
@@ -7431,11 +7426,6 @@
"count": 1
}
},
"web/types/lamejs.d.ts": {
"ts/no-explicit-any": {
"count": 3
}
},
"web/types/pipeline.tsx": {
"ts/no-explicit-any": {
"count": 3
+43 -19
View File
@@ -84,6 +84,9 @@ catalogs:
'@mdx-js/rollup':
specifier: 3.1.1
version: 3.1.1
'@mediabunny/mp3-encoder':
specifier: 1.49.0
version: 1.49.0
'@monaco-editor/react':
specifier: 4.7.0
version: 4.7.0
@@ -429,9 +432,6 @@ catalogs:
ky:
specifier: 2.0.2
version: 2.0.2
lamejs:
specifier: 1.2.1
version: 1.2.1
lexical:
specifier: 0.46.0
version: 0.46.0
@@ -441,6 +441,9 @@ catalogs:
loro-crdt:
specifier: 1.13.6
version: 1.13.6
mediabunny:
specifier: 1.49.0
version: 1.49.0
mermaid:
specifier: 11.16.0
version: 11.16.0
@@ -1108,6 +1111,9 @@ importers:
'@lexical/utils':
specifier: 'catalog:'
version: 0.46.0(typescript@6.0.3)
'@mediabunny/mp3-encoder':
specifier: 'catalog:'
version: 1.49.0(mediabunny@1.49.0)
'@monaco-editor/react':
specifier: 'catalog:'
version: 4.7.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -1267,15 +1273,15 @@ importers:
ky:
specifier: 'catalog:'
version: 2.0.2
lamejs:
specifier: 'catalog:'
version: 1.2.1
lexical:
specifier: 'catalog:'
version: 0.46.0(typescript@6.0.3)
loro-crdt:
specifier: 'catalog:'
version: 1.13.6
mediabunny:
specifier: 'catalog:'
version: 1.49.0
mermaid:
specifier: 'catalog:'
version: 11.16.0
@@ -2983,6 +2989,11 @@ packages:
peerDependencies:
rollup: 4.62.2
'@mediabunny/mp3-encoder@1.49.0':
resolution: {integrity: sha512-b2US4tYGGou3Yo4nZnCU3NoIu8DPW6UO6WFz5KE7gbVH3iCQRT8hSg6nTAVHtO+isvm9Vtl0fsF0KnSVpAPwRQ==}
peerDependencies:
mediabunny: ^1.0.0
'@mermaid-js/parser@1.2.0':
resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==}
@@ -4886,6 +4897,12 @@ packages:
'@types/doctrine@0.0.9':
resolution: {integrity: sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA==}
'@types/dom-mediacapture-transform@0.1.11':
resolution: {integrity: sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ==}
'@types/dom-webcodecs@0.1.13':
resolution: {integrity: sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==}
'@types/esrecurse@4.3.1':
resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==}
@@ -7492,9 +7509,6 @@ packages:
resolution: {integrity: sha512-/GmXpo9F9W+f8n4Ivr2iH+7h7wL7jLbLKWkMlpflcCRb6kGjBfTlASEXaZ9qUgNTn4VgS0P2pwxxzQ4EM6Ulgg==}
engines: {node: '>=22'}
lamejs@1.2.1:
resolution: {integrity: sha512-s7bxvjvYthw6oPLCm5pFxvA84wUROODB8jEO2+CE1adhKgrIvVOlmMgY8zyugxGrvRaDHNJanOiS21/emty6dQ==}
language-subtag-registry@0.3.23:
resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==}
@@ -7764,6 +7778,9 @@ packages:
mdn-data@2.28.1:
resolution: {integrity: sha512-U9w+PzSZ00Z5m9rZ5ARVFL5xOfuCHdKYi/1RRwDCJsboFgJDNT3zT6PIPD7mZQYaQLhsZM3GfDRgSMRHhSmVng==}
mediabunny@1.49.0:
resolution: {integrity: sha512-hDMtS/q22GFjyB3Yum+a6NHhDtVnye6hgaYTjx3DPUfnckRjzxFmXmCc3UQXdvo7d6PUCl7KM6Y9MRWzlvnAJQ==}
merge2@1.4.1:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
@@ -9576,9 +9593,6 @@ packages:
'@types/react':
optional: true
use-strict@1.0.1:
resolution: {integrity: sha512-IeiWvvEXfW5ltKVMkxq6FvNf2LojMKvB2OCeja6+ct24S1XOmQw2dGr2JyndwACWAGJva9B7yPHwAmeA9QCqAQ==}
use-sync-external-store@1.6.0:
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
peerDependencies:
@@ -11709,6 +11723,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@mediabunny/mp3-encoder@1.49.0(mediabunny@1.49.0)':
dependencies:
mediabunny: 1.49.0
'@mermaid-js/parser@1.2.0':
dependencies:
'@chevrotain/types': 11.1.2
@@ -13340,6 +13358,12 @@ snapshots:
'@types/doctrine@0.0.9': {}
'@types/dom-mediacapture-transform@0.1.11':
dependencies:
'@types/dom-webcodecs': 0.1.13
'@types/dom-webcodecs@0.1.13': {}
'@types/esrecurse@4.3.1': {}
'@types/estree-jsx@1.0.5':
@@ -16768,10 +16792,6 @@ snapshots:
ky@2.0.2: {}
lamejs@1.2.1:
dependencies:
use-strict: 1.0.1
language-subtag-registry@0.3.23: {}
language-tags@1.0.9:
@@ -17141,6 +17161,11 @@ snapshots:
mdn-data@2.28.1: {}
mediabunny@1.49.0:
dependencies:
'@types/dom-mediacapture-transform': 0.1.11
'@types/dom-webcodecs': 0.1.13
merge2@1.4.1: {}
mermaid@11.16.0:
@@ -19426,8 +19451,6 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.17
use-strict@1.0.1: {}
use-sync-external-store@1.6.0(react@19.2.7):
dependencies:
react: 19.2.7
@@ -20066,6 +20089,7 @@ time:
'@mdx-js/loader@3.1.1': '2025-08-29T18:03:05.606Z'
'@mdx-js/react@3.1.1': '2025-08-29T18:02:56.462Z'
'@mdx-js/rollup@3.1.1': '2025-08-29T18:03:10.680Z'
'@mediabunny/mp3-encoder@1.49.0': '2026-06-18T17:50:53.396Z'
'@monaco-editor/react@4.7.0': '2025-02-13T16:13:41.390Z'
'@napi-rs/keyring@1.3.0': '2026-04-30T09:56:44.246Z'
'@next/eslint-plugin-next@16.2.9': '2026-06-09T23:01:50.881Z'
@@ -20182,11 +20206,11 @@ time:
katex@0.17.0: '2026-05-22T08:06:26.967Z'
knip@6.22.0: '2026-06-27T13:30:51.275Z'
ky@2.0.2: '2026-04-21T08:58:46.923Z'
lamejs@1.2.1: '2021-12-02T15:44:40.036Z'
lexical-code-no-prism@0.41.0: '2026-03-08T16:50:40.266Z'
lexical@0.46.0: '2026-06-26T04:53:00.532Z'
lockfile@1.0.4: '2018-04-17T00:36:12.565Z'
loro-crdt@1.13.6: '2026-06-21T15:40:04.671Z'
mediabunny@1.49.0: '2026-06-18T17:50:36.336Z'
mermaid@11.16.0: '2026-06-25T11:30:40.280Z'
mime@4.1.0: '2025-09-12T17:53:01.376Z'
mitt@3.0.1: '2023-07-04T17:31:47.638Z'
+2 -1
View File
@@ -78,6 +78,7 @@ catalog:
'@mdx-js/loader': 3.1.1
'@mdx-js/react': 3.1.1
'@mdx-js/rollup': 3.1.1
'@mediabunny/mp3-encoder': 1.49.0
'@monaco-editor/react': 4.7.0
'@napi-rs/keyring': 1.3.0
'@next/eslint-plugin-next': 16.2.9
@@ -193,10 +194,10 @@ catalog:
katex: 0.17.0
knip: 6.22.0
ky: 2.0.2
lamejs: 1.2.1
lexical: 0.46.0
lockfile: 1.0.4
loro-crdt: 1.13.6
mediabunny: 1.49.0
mermaid: 11.16.0
mime: 4.1.0
mitt: 3.0.1
@@ -1,196 +1,285 @@
import { convertToMp3 } from '../utils'
// ── Hoisted mocks ──
const mocks = vi.hoisted(() => {
const readHeader = vi.fn()
const encodeBuffer = vi.fn()
const flush = vi.fn()
const state = {
targets: [] as Array<{ buffer: ArrayBuffer | null }>,
outputs: [] as Array<{
format: unknown
target: { buffer: ArrayBuffer | null }
addAudioTrack: ReturnType<typeof vi.fn>
start: ReturnType<typeof vi.fn>
finalize: ReturnType<typeof vi.fn>
}>,
sources: [] as Array<{
encodingConfig: unknown
add: ReturnType<typeof vi.fn>
close: ReturnType<typeof vi.fn>
}>,
samples: [] as Array<{
init: {
data: Int16Array
format: string
numberOfChannels: number
sampleRate: number
timestamp: number
}
close: ReturnType<typeof vi.fn>
}>,
formats: [] as unknown[],
outputBuffer: new Uint8Array([1, 2, 3, 4, 5]).buffer as ArrayBuffer | null,
}
return { readHeader, encodeBuffer, flush }
const registerMp3Encoder = vi.fn()
const canEncodeAudio = vi.fn()
class MockBufferTarget {
buffer: ArrayBuffer | null = state.outputBuffer
constructor() {
state.targets.push(this)
}
}
class MockMp3OutputFormat {
constructor() {
state.formats.push(this)
}
}
class MockOutput {
format: unknown
target: { buffer: ArrayBuffer | null }
addAudioTrack = vi.fn()
start = vi.fn(async () => {})
finalize = vi.fn(async () => {})
constructor(options: { format: unknown, target: { buffer: ArrayBuffer | null } }) {
this.format = options.format
this.target = options.target
state.outputs.push(this)
}
}
class MockAudioSampleSource {
encodingConfig: unknown
add = vi.fn(async () => {})
close = vi.fn()
constructor(encodingConfig: unknown) {
this.encodingConfig = encodingConfig
state.sources.push(this)
}
}
class MockAudioSample {
init: {
data: Int16Array
format: string
numberOfChannels: number
sampleRate: number
timestamp: number
}
close = vi.fn()
constructor(init: MockAudioSample['init']) {
this.init = init
state.samples.push(this)
}
}
return {
state,
registerMp3Encoder,
canEncodeAudio,
MockAudioSample,
MockAudioSampleSource,
MockBufferTarget,
MockMp3OutputFormat,
MockOutput,
}
})
vi.mock('lamejs', () => ({
default: {
WavHeader: {
readHeader: mocks.readHeader,
},
Mp3Encoder: class MockMp3Encoder {
encodeBuffer = mocks.encodeBuffer
flush = mocks.flush
},
},
vi.mock('@mediabunny/mp3-encoder', () => ({
registerMp3Encoder: mocks.registerMp3Encoder,
}))
vi.mock('lamejs/src/js/BitStream', () => ({ default: {} }))
vi.mock('lamejs/src/js/Lame', () => ({ default: {} }))
vi.mock('lamejs/src/js/MPEGMode', () => ({ default: {} }))
vi.mock('mediabunny', () => ({
AudioSample: mocks.MockAudioSample,
AudioSampleSource: mocks.MockAudioSampleSource,
BufferTarget: mocks.MockBufferTarget,
Mp3OutputFormat: mocks.MockMp3OutputFormat,
Output: mocks.MockOutput,
canEncodeAudio: mocks.canEncodeAudio,
}))
// ── helpers ──
function createWavHeader(channels: number, sampleRate: number) {
const view = new DataView(new ArrayBuffer(44))
view.setUint16(22, channels, true)
view.setUint32(24, sampleRate, true)
return view
}
function createPcmDataView(samples: number[]) {
const view = new DataView(new ArrayBuffer(samples.length * 2))
samples.forEach((sample, index) => {
view.setInt16(index * 2, sample, true)
})
return view
}
/** Build a fake recorder whose getChannelData returns DataView-like objects with .buffer and .byteLength. */
function createMockRecorder(opts: {
channels: number
sampleRate: number
leftSamples: number[]
rightSamples?: number[]
}) {
const toDataView = (samples: number[]) => {
const buf = new ArrayBuffer(samples.length * 2)
const view = new DataView(buf)
samples.forEach((v, i) => {
view.setInt16(i * 2, v, true)
})
return view
}
const leftView = toDataView(opts.leftSamples)
const rightView = opts.rightSamples ? toDataView(opts.rightSamples) : null
mocks.readHeader.mockReturnValue({
channels: opts.channels,
sampleRate: opts.sampleRate,
})
return {
getWAV: vi.fn(() => new ArrayBuffer(44)),
getWAV: vi.fn(() => createWavHeader(opts.channels, opts.sampleRate)),
getChannelData: vi.fn(() => ({
left: leftView,
right: rightView,
left: createPcmDataView(opts.leftSamples),
right: opts.rightSamples ? createPcmDataView(opts.rightSamples) : null,
})),
}
}
async function expectBlobBytes(blob: Blob, bytes: number[]) {
expect(new Uint8Array(await blob.arrayBuffer())).toEqual(new Uint8Array(bytes))
}
function getOnly<T>(items: T[]) {
expect(items).toHaveLength(1)
return items[0]!
}
describe('convertToMp3', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.state.targets = []
mocks.state.outputs = []
mocks.state.sources = []
mocks.state.samples = []
mocks.state.formats = []
mocks.state.outputBuffer = new Uint8Array([1, 2, 3, 4, 5]).buffer
mocks.canEncodeAudio.mockResolvedValue(false)
})
it('should convert mono WAV data to an MP3 blob', () => {
it('should encode mono recorder PCM data with Mediabunny', async () => {
const recorder = createMockRecorder({
channels: 1,
sampleRate: 44100,
leftSamples: [100, 200, 300, 400],
sampleRate: 16000,
leftSamples: [-32768, 0, 32767],
})
mocks.encodeBuffer.mockReturnValue(new Int8Array([1, 2, 3]))
mocks.flush.mockReturnValue(new Int8Array([4, 5]))
const result = convertToMp3(recorder)
const result = await convertToMp3(recorder)
const output = getOnly(mocks.state.outputs)
const source = getOnly(mocks.state.sources)
const sample = getOnly(mocks.state.samples)
expect(result).toBeInstanceOf(Blob)
expect(result.type).toBe('audio/mp3')
expect(mocks.encodeBuffer).toHaveBeenCalled()
// Mono: encodeBuffer called with only left data
const firstCall = mocks.encodeBuffer.mock.calls[0]
expect(firstCall).toHaveLength(1)
expect(mocks.flush).toHaveBeenCalled()
expect(mocks.canEncodeAudio).toHaveBeenCalledWith('mp3')
expect(mocks.registerMp3Encoder).toHaveBeenCalled()
expect(source.encodingConfig).toEqual({
codec: 'mp3',
bitrate: 128000,
})
expect(sample.init).toMatchObject({
format: 's16',
numberOfChannels: 1,
sampleRate: 16000,
timestamp: 0,
})
expect(Array.from(sample.init.data)).toEqual([-32768, 0, 32767])
expect(output.addAudioTrack).toHaveBeenCalledWith(source)
expect(output.start).toHaveBeenCalled()
expect(source.add).toHaveBeenCalledWith(sample)
expect(sample.close).toHaveBeenCalled()
expect(source.close).toHaveBeenCalled()
expect(output.finalize).toHaveBeenCalled()
await expectBlobBytes(result, [1, 2, 3, 4, 5])
})
it('should convert stereo WAV data to an MP3 blob', () => {
it('should encode stereo recorder PCM data as interleaved samples', async () => {
const recorder = createMockRecorder({
channels: 2,
sampleRate: 48000,
leftSamples: [100, 200],
rightSamples: [300, 400],
leftSamples: [100, -100],
rightSamples: [300, -300],
})
mocks.encodeBuffer.mockReturnValue(new Int8Array([10, 20]))
mocks.flush.mockReturnValue(new Int8Array([30]))
const result = await convertToMp3(recorder)
const sample = getOnly(mocks.state.samples)
const result = convertToMp3(recorder)
expect(result).toBeInstanceOf(Blob)
expect(result.type).toBe('audio/mp3')
// Stereo: encodeBuffer called with left AND right
const firstCall = mocks.encodeBuffer.mock.calls[0]
expect(firstCall).toHaveLength(2)
expect(sample.init).toMatchObject({
format: 's16',
numberOfChannels: 2,
sampleRate: 48000,
timestamp: 0,
})
expect(Array.from(sample.init.data)).toEqual([100, 300, -100, -300])
await expectBlobBytes(result, [1, 2, 3, 4, 5])
})
it('should skip empty encoded buffers', () => {
it('should skip custom encoder registration when native MP3 encoding is available', async () => {
mocks.canEncodeAudio.mockResolvedValue(true)
const recorder = createMockRecorder({
channels: 1,
sampleRate: 44100,
leftSamples: [100, 200],
leftSamples: [100],
})
mocks.encodeBuffer.mockReturnValue(new Int8Array(0))
mocks.flush.mockReturnValue(new Int8Array(0))
await convertToMp3(recorder)
const result = convertToMp3(recorder)
expect(result).toBeInstanceOf(Blob)
expect(result.type).toBe('audio/mp3')
expect(result.size).toBe(0)
expect(mocks.registerMp3Encoder).not.toHaveBeenCalled()
})
it('should include flush data when flush returns non-empty buffer', () => {
it('should return an empty MP3 blob when the target has no buffer', async () => {
mocks.state.outputBuffer = null
const recorder = createMockRecorder({
channels: 1,
sampleRate: 22050,
leftSamples: [1],
})
mocks.encodeBuffer.mockReturnValue(new Int8Array(0))
mocks.flush.mockReturnValue(new Int8Array([99, 98, 97]))
const result = await convertToMp3(recorder)
const result = convertToMp3(recorder)
expect(result).toBeInstanceOf(Blob)
expect(result.size).toBe(3)
expect(result.size).toBe(0)
})
it('should omit flush data when flush returns empty buffer', () => {
it('should reject unsupported WAV channel counts', async () => {
const recorder = createMockRecorder({
channels: 1,
channels: 3,
sampleRate: 44100,
leftSamples: [10, 20],
leftSamples: [100],
})
mocks.encodeBuffer.mockReturnValue(new Int8Array([1, 2]))
mocks.flush.mockReturnValue(new Int8Array(0))
const result = convertToMp3(recorder)
expect(result).toBeInstanceOf(Blob)
expect(result.size).toBe(2)
await expect(convertToMp3(recorder)).rejects.toThrow('Unsupported WAV channel count: 3')
expect(mocks.canEncodeAudio).not.toHaveBeenCalled()
})
it('should process multiple chunks when sample count exceeds maxSamples (1152)', () => {
const samples = Array.from({ length: 2400 }, (_, i) => i % 32767)
const recorder = createMockRecorder({
channels: 1,
sampleRate: 44100,
leftSamples: samples,
})
mocks.encodeBuffer.mockReturnValue(new Int8Array([1]))
mocks.flush.mockReturnValue(new Int8Array(0))
const result = convertToMp3(recorder)
expect(mocks.encodeBuffer.mock.calls.length).toBeGreaterThan(1)
expect(result).toBeInstanceOf(Blob)
})
it('should encode stereo with right channel subarray', () => {
it('should reject stereo WAV data without a right channel', async () => {
const recorder = createMockRecorder({
channels: 2,
sampleRate: 44100,
leftSamples: [100, 200, 300],
rightSamples: [400, 500, 600],
leftSamples: [100],
})
mocks.encodeBuffer.mockReturnValue(new Int8Array([5, 6, 7]))
mocks.flush.mockReturnValue(new Int8Array([8]))
await expect(convertToMp3(recorder)).rejects.toThrow('Missing right channel data for stereo WAV')
})
const result = convertToMp3(recorder)
it('should reject stereo WAV data with mismatched channel lengths', async () => {
const recorder = createMockRecorder({
channels: 2,
sampleRate: 44100,
leftSamples: [100, 200],
rightSamples: [300],
})
expect(result).toBeInstanceOf(Blob)
for (const call of mocks.encodeBuffer.mock.calls) {
expect(call).toHaveLength(2)
expect(call[0]).toBeInstanceOf(Int16Array)
expect(call[1]).toBeInstanceOf(Int16Array)
}
await expect(convertToMp3(recorder)).rejects.toThrow('Stereo WAV channel sample counts do not match')
})
})
@@ -1,3 +1,4 @@
import type { AudioRecorder } from './utils'
import { cn } from '@langgenius/dify-ui/cn'
import { useRafInterval } from 'ahooks'
import Recorder from 'js-audio-recorder'
@@ -21,12 +22,12 @@ const VoiceInput = ({
wordTimestamps,
}: VoiceInputTypes) => {
const { t } = useTranslation()
const recorder = useRef(new Recorder({
const recorder = useRef<Recorder & AudioRecorder>(new Recorder({
sampleBits: 16,
sampleRate: 16000,
numChannels: 1,
compiling: false,
}))
}) as Recorder & AudioRecorder)
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const ctxRef = useRef<CanvasRenderingContext2D | null>(null)
const drawRecordId = useRef<number | null>(null)
@@ -83,7 +84,7 @@ const VoiceInput = ({
const canvas = canvasRef.current!
const ctx = ctxRef.current!
ctx.clearRect(0, 0, canvas.width, canvas.height)
const mp3Blob = convertToMp3(recorder.current)
const mp3Blob = await convertToMp3(recorder.current)
const mp3File = new File([mp3Blob], 'temp.mp3', { type: 'audio/mp3' })
const formData = new FormData()
formData.append('file', mp3File)
+102 -45
View File
@@ -1,54 +1,111 @@
import lamejs from 'lamejs'
import BitStream from 'lamejs/src/js/BitStream'
import Lame from 'lamejs/src/js/Lame'
import MPEGMode from 'lamejs/src/js/MPEGMode'
import { registerMp3Encoder } from '@mediabunny/mp3-encoder'
import {
AudioSample,
AudioSampleSource,
BufferTarget,
canEncodeAudio,
Mp3OutputFormat,
Output,
} from 'mediabunny'
/* v8 ignore next - @preserve */
if (globalThis) {
(globalThis as any).MPEGMode = MPEGMode
; (globalThis as any).Lame = Lame
; (globalThis as any).BitStream = BitStream
type SupportedChannelCount = 1 | 2
type RecorderChannelData = {
left: DataView
right?: DataView | null
}
export const convertToMp3 = (recorder: any) => {
const wav = lamejs.WavHeader.readHeader(recorder.getWAV())
const { channels, sampleRate } = wav
const mp3enc = new lamejs.Mp3Encoder(channels, sampleRate, 128)
export type AudioRecorder = {
getWAV: () => ArrayBuffer | DataView
getChannelData: () => RecorderChannelData
}
const wavChannelsOffset = 22
const wavSampleRateOffset = 24
const bytesPerSample = 2
const mp3Bitrate = 128_000
const ensureMp3Encoder = async () => {
if (!(await canEncodeAudio('mp3')))
registerMp3Encoder()
}
const readWavInfo = (wav: ArrayBuffer | DataView) => {
const view = wav instanceof DataView ? wav : new DataView(wav)
const channels = view.getUint16(wavChannelsOffset, true)
if (channels !== 1 && channels !== 2)
throw new Error(`Unsupported WAV channel count: ${channels}`)
return {
channels: channels as SupportedChannelCount,
sampleRate: view.getUint32(wavSampleRateOffset, true),
}
}
const readInt16Samples = (data: DataView) => {
const samples = new Int16Array(data.byteLength / bytesPerSample)
for (let i = 0; i < samples.length; i++)
samples[i] = data.getInt16(i * bytesPerSample, true)
return samples
}
const createPcmData = (data: RecorderChannelData, channels: SupportedChannelCount) => {
const leftSamples = readInt16Samples(data.left)
if (channels === 1)
return leftSamples
if (!data.right)
throw new Error('Missing right channel data for stereo WAV')
const rightSamples = readInt16Samples(data.right)
if (leftSamples.length !== rightSamples.length)
throw new Error('Stereo WAV channel sample counts do not match')
const samples = new Int16Array(leftSamples.length * channels)
for (let i = 0; i < leftSamples.length; i++) {
samples[i * channels] = leftSamples[i]!
samples[i * channels + 1] = rightSamples[i]!
}
return samples
}
export const convertToMp3 = async (recorder: AudioRecorder) => {
const { channels, sampleRate } = readWavInfo(recorder.getWAV())
const result = recorder.getChannelData()
const buffer: BlobPart[] = []
const pcmData = createPcmData(result, channels)
const leftData = result.left && new Int16Array(result.left.buffer, 0, result.left.byteLength / 2)
const rightData = result.right && new Int16Array(result.right.buffer, 0, result.right.byteLength / 2)
const remaining = leftData.length + (rightData ? rightData.length : 0)
await ensureMp3Encoder()
const maxSamples = 1152
const toArrayBuffer = (bytes: Int8Array) => {
const arrayBuffer = new ArrayBuffer(bytes.length)
new Uint8Array(arrayBuffer).set(bytes)
return arrayBuffer
}
const target = new BufferTarget()
const output = new Output({
format: new Mp3OutputFormat(),
target,
})
const source = new AudioSampleSource({
codec: 'mp3',
bitrate: mp3Bitrate,
})
const sample = new AudioSample({
data: pcmData,
format: 's16',
numberOfChannels: channels,
sampleRate,
timestamp: 0,
})
for (let i = 0; i < remaining; i += maxSamples) {
const left = leftData.subarray(i, i + maxSamples)
let right = null
let mp3buf = null
output.addAudioTrack(source)
await output.start()
await source.add(sample)
sample.close()
source.close()
await output.finalize()
if (channels === 2) {
right = rightData.subarray(i, i + maxSamples)
mp3buf = mp3enc.encodeBuffer(left, right)
}
else {
mp3buf = mp3enc.encodeBuffer(left)
}
if (mp3buf.length > 0)
buffer.push(toArrayBuffer(mp3buf))
}
const enc = mp3enc.flush()
if (enc.length > 0)
buffer.push(toArrayBuffer(enc))
return new Blob(buffer, { type: 'audio/mp3' })
return new Blob([target.buffer ?? new ArrayBuffer(0)], { type: 'audio/mp3' })
}
-5
View File
@@ -3,11 +3,6 @@ import './types/jsx'
import './types/mdx'
import './types/assets'
declare module 'lamejs';
declare module 'lamejs/src/js/MPEGMode';
declare module 'lamejs/src/js/Lame';
declare module 'lamejs/src/js/BitStream';
declare global {
// Google Analytics gtag types
type GtagEventParams = {
+2 -1
View File
@@ -65,6 +65,7 @@
"@lexical/selection": "catalog:",
"@lexical/text": "catalog:",
"@lexical/utils": "catalog:",
"@mediabunny/mp3-encoder": "catalog:",
"@monaco-editor/react": "catalog:",
"@orpc/client": "catalog:",
"@orpc/contract": "catalog:",
@@ -118,9 +119,9 @@
"jsonschema": "catalog:",
"katex": "catalog:",
"ky": "catalog:",
"lamejs": "catalog:",
"lexical": "catalog:",
"loro-crdt": "catalog:",
"mediabunny": "catalog:",
"mermaid": "catalog:",
"mime": "catalog:",
"mitt": "catalog:",
-36
View File
@@ -1,36 +0,0 @@
declare module 'lamejs' {
export class Mp3Encoder {
constructor(channels: number, sampleRate: number, bitRate: number)
encodeBuffer(left: Int16Array, right?: Int16Array | null): Int8Array
flush(): Int8Array
}
export class WavHeader {
static readHeader(data: DataView): {
channels: number
sampleRate: number
}
}
const lamejs: {
Mp3Encoder: typeof Mp3Encoder
WavHeader: typeof WavHeader
}
export default lamejs
}
declare module 'lamejs/src/js/MPEGMode' {
const MPEGMode: any
export default MPEGMode
}
declare module 'lamejs/src/js/Lame' {
const Lame: any
export default Lame
}
declare module 'lamejs/src/js/BitStream' {
const BitStream: any
export default BitStream
}