mirror of
https://github.com/moeru-ai/airi.git
synced 2026-08-14 00:48:06 +00:00
refactor(audio,stage-ui): simplify code
This commit is contained in:
@@ -42,7 +42,8 @@
|
||||
"scripts": {
|
||||
"dev": "pnpm run build",
|
||||
"build": "tsdown",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test:run": "vitest run"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": ">=3"
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { toWav, toWavFromPCM16 } from './wav'
|
||||
|
||||
describe('toWav', () => {
|
||||
it('converts Float32 samples to PCM16 bytes by default', () => {
|
||||
const samples = new Float32Array([-1, 0, 1])
|
||||
const wav = toWav(samples.buffer, 24000)
|
||||
const view = new DataView(wav)
|
||||
|
||||
expect(view.getInt16(44, true)).toBe(-32768)
|
||||
expect(view.getInt16(46, true)).toBe(0)
|
||||
expect(view.getInt16(48, true)).toBe(32767)
|
||||
})
|
||||
|
||||
it('preserves PCM16 bytes and writes the WAV metadata', () => {
|
||||
const pcmBytes = new Uint8Array([0x00, 0x80, 0xFF, 0x7F])
|
||||
const wav = toWavFromPCM16(pcmBytes, 24000)
|
||||
const view = new DataView(wav)
|
||||
|
||||
expect(new TextDecoder().decode(new Uint8Array(wav, 0, 4))).toBe('RIFF')
|
||||
expect(view.getUint32(4, true)).toBe(40)
|
||||
expect(new TextDecoder().decode(new Uint8Array(wav, 8, 4))).toBe('WAVE')
|
||||
expect(view.getUint16(20, true)).toBe(1)
|
||||
expect(view.getUint16(22, true)).toBe(1)
|
||||
expect(view.getUint32(24, true)).toBe(24000)
|
||||
expect(view.getUint32(28, true)).toBe(48000)
|
||||
expect(view.getUint16(32, true)).toBe(2)
|
||||
expect(view.getUint16(34, true)).toBe(16)
|
||||
expect(view.getUint32(40, true)).toBe(pcmBytes.byteLength)
|
||||
expect([...new Uint8Array(wav, 44)]).toEqual([...pcmBytes])
|
||||
})
|
||||
})
|
||||
@@ -6,46 +6,66 @@ function writeString(dataView: DataView, offset: number, string: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export function toWav(buffer: ArrayBufferLike, sampleRate: number, channel = 1) {
|
||||
const samples = new Float32Array(buffer) // allows indexing
|
||||
const numChannels = channel
|
||||
const numSamples = samples.length
|
||||
|
||||
// Create the WAV file container
|
||||
const arrayBuffer = new ArrayBuffer(44 + numSamples * 2)
|
||||
function createWavBuffer(dataSize: number, sampleRate: number, channel: number): ArrayBuffer {
|
||||
const bitsPerSample = 16
|
||||
const byteRate = sampleRate * channel * (bitsPerSample / 8)
|
||||
const blockAlign = channel * (bitsPerSample / 8)
|
||||
const arrayBuffer = new ArrayBuffer(44 + dataSize)
|
||||
const dataView = new DataView(arrayBuffer)
|
||||
|
||||
// RIFF chunk descriptor
|
||||
writeString(dataView, 0, 'RIFF')
|
||||
dataView.setUint32(4, 36 + numSamples * 2, true)
|
||||
dataView.setUint32(4, 36 + dataSize, true)
|
||||
writeString(dataView, 8, 'WAVE')
|
||||
|
||||
// fmt sub-chunk
|
||||
writeString(dataView, 12, 'fmt ')
|
||||
dataView.setUint32(16, 16, true)
|
||||
dataView.setUint16(20, 1, true) // PCM format
|
||||
dataView.setUint16(22, numChannels, true)
|
||||
dataView.setUint16(20, 1, true)
|
||||
dataView.setUint16(22, channel, true)
|
||||
dataView.setUint32(24, sampleRate, true)
|
||||
dataView.setUint32(28, sampleRate * numChannels * 2, true) // byte rate
|
||||
dataView.setUint16(32, numChannels * 2, true) // block align
|
||||
dataView.setUint32(28, byteRate, true)
|
||||
dataView.setUint16(32, blockAlign, true)
|
||||
dataView.setUint16(34, bitsPerSample, true)
|
||||
|
||||
dataView.setUint16(34, 16, true) // bits per sample
|
||||
|
||||
// data sub-chunk
|
||||
writeString(dataView, 36, 'data')
|
||||
dataView.setUint32(40, numSamples * 2, true)
|
||||
dataView.setUint32(40, dataSize, true)
|
||||
|
||||
// PCM samples
|
||||
const offset = 44
|
||||
for (let i = 0; i < numSamples; i++) {
|
||||
return arrayBuffer
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
const arrayBuffer = createWavBuffer(samples.length * 2, sampleRate, channel)
|
||||
const dataView = new DataView(arrayBuffer)
|
||||
|
||||
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(offset + i * 2, value, true)
|
||||
dataView.setInt16(44 + i * 2, value, true)
|
||||
}
|
||||
|
||||
return arrayBuffer
|
||||
}
|
||||
|
||||
/**
|
||||
* 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))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
})
|
||||
@@ -2,6 +2,8 @@ import type { SpeechProviderWithExtraOptions } from '@xsai-ext/providers/utils'
|
||||
|
||||
import type { ModelInfo, ProviderMetadata, VoiceInfo } from '../providers'
|
||||
|
||||
import { toWavFromPCM16 } from '@proj-airi/audio/encoding'
|
||||
|
||||
const PROVIDER_ID = 'google-gemini-audio-speech'
|
||||
const DEFAULT_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta'
|
||||
const DEFAULT_MODEL = 'gemini-2.5-flash-preview-tts'
|
||||
@@ -45,42 +47,6 @@ const GOOGLE_GEMINI_TTS_VOICES: [string, string][] = [
|
||||
['Sulafat', 'Warm'],
|
||||
]
|
||||
|
||||
/** Wraps raw PCM16 mono data in a minimal WAV container. */
|
||||
function wrapPCM16InWAV(pcmBytes: Uint8Array, sampleRate = 24000): Uint8Array {
|
||||
const numChannels = 1
|
||||
const bitsPerSample = 16
|
||||
const byteRate = sampleRate * numChannels * (bitsPerSample / 8)
|
||||
const blockAlign = numChannels * (bitsPerSample / 8)
|
||||
const header = new ArrayBuffer(44)
|
||||
const view = new DataView(header)
|
||||
|
||||
const writeStr = (offset: number, str: string) => {
|
||||
for (let i = 0; i < str.length; i++)
|
||||
view.setUint8(offset + i, str.charCodeAt(i))
|
||||
}
|
||||
|
||||
writeStr(0, 'RIFF')
|
||||
view.setUint32(4, 36 + pcmBytes.length, true)
|
||||
writeStr(8, 'WAVE')
|
||||
|
||||
writeStr(12, 'fmt ')
|
||||
view.setUint32(16, 16, true)
|
||||
view.setUint16(20, 1, true)
|
||||
view.setUint16(22, numChannels, true)
|
||||
view.setUint32(24, sampleRate, true)
|
||||
view.setUint32(28, byteRate, true)
|
||||
view.setUint16(32, blockAlign, true)
|
||||
view.setUint16(34, bitsPerSample, true)
|
||||
|
||||
writeStr(36, 'data')
|
||||
view.setUint32(40, pcmBytes.length, true)
|
||||
|
||||
const wav = new Uint8Array(44 + pcmBytes.length)
|
||||
wav.set(new Uint8Array(header), 0)
|
||||
wav.set(pcmBytes, 44)
|
||||
return wav
|
||||
}
|
||||
|
||||
/** Decodes a base64 string into a Uint8Array. */
|
||||
function base64ToBytes(base64: string): Uint8Array {
|
||||
const binaryString = atob(base64)
|
||||
@@ -169,12 +135,9 @@ function createAudioFetch(apiKey: string, baseUrl: string) {
|
||||
}
|
||||
|
||||
const pcmBytes = base64ToBytes(audioBase64)
|
||||
const wavBytes = wrapPCM16InWAV(pcmBytes)
|
||||
const wavBuffer = toWavFromPCM16(pcmBytes, 24000)
|
||||
|
||||
// NOTICE: wrapPCM16InWAV always creates a fresh Uint8Array, so .buffer is the full
|
||||
// backing ArrayBuffer (not a subarray view into a larger buffer). The `as ArrayBuffer`
|
||||
// cast is needed because .buffer returns ArrayBufferLike in newer TypeScript.
|
||||
return new Response(wavBytes.buffer as ArrayBuffer, {
|
||||
return new Response(wavBuffer, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'audio/wav' },
|
||||
})
|
||||
|
||||
@@ -20,21 +20,6 @@ function normalizeBaseUrl(value: unknown): string {
|
||||
return base
|
||||
}
|
||||
|
||||
function shouldLog(): boolean {
|
||||
try {
|
||||
// Opt-in via localStorage to minimize I/O in production
|
||||
return typeof localStorage !== 'undefined' && localStorage.getItem('airi:debug') === '1'
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function logWarn(...args: unknown[]) {
|
||||
if (shouldLog())
|
||||
console.warn(...args)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps transcription providers so OpenAI audio options like `language` and `prompt` are preserved.
|
||||
*/
|
||||
@@ -194,8 +179,8 @@ export function buildOpenAICompatibleProvider(
|
||||
detected = models[0].id
|
||||
}
|
||||
catch (e) {
|
||||
logWarn(`Model auto-detection failed: ${(e as Error).message}`)
|
||||
logWarn('Falling back to default test model for validation checks.')
|
||||
console.warn(`Model auto-detection failed: ${(e as Error).message}`)
|
||||
console.warn('Falling back to default test model for validation checks.')
|
||||
try {
|
||||
if (capabilities?.listModels) {
|
||||
const models = await capabilities.listModels(config)
|
||||
@@ -206,7 +191,7 @@ export function buildOpenAICompatibleProvider(
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
logWarn(`Model auto-detection via capabilities.listModels also failed: ${(e as Error).message}`)
|
||||
console.warn(`Model auto-detection via capabilities.listModels also failed: ${(e as Error).message}`)
|
||||
}
|
||||
}
|
||||
return detected
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { SpeechProvider } from '@xsai-ext/providers/utils'
|
||||
|
||||
import type { ModelInfo, ProviderMetadata, VoiceInfo } from '../../providers'
|
||||
|
||||
import { toWavFromPCM16 } from '@proj-airi/audio/encoding'
|
||||
|
||||
import { OPENROUTER_ATTRIBUTION_HEADERS } from '../../../libs/providers/providers/openrouter-ai'
|
||||
|
||||
const DEFAULT_BASE_URL = 'https://openrouter.ai/api/v1/'
|
||||
@@ -94,42 +96,6 @@ function decodeBase64PCM(chunks: string[]): Uint8Array {
|
||||
return bytes
|
||||
}
|
||||
|
||||
/** Wraps raw PCM16 mono data in a minimal WAV container. */
|
||||
function wrapPCM16InWAV(pcmBytes: Uint8Array, sampleRate = 24000): Uint8Array {
|
||||
const numChannels = 1
|
||||
const bitsPerSample = 16
|
||||
const byteRate = sampleRate * numChannels * (bitsPerSample / 8)
|
||||
const blockAlign = numChannels * (bitsPerSample / 8)
|
||||
const header = new ArrayBuffer(44)
|
||||
const view = new DataView(header)
|
||||
|
||||
const writeStr = (offset: number, str: string) => {
|
||||
for (let i = 0; i < str.length; i++)
|
||||
view.setUint8(offset + i, str.charCodeAt(i))
|
||||
}
|
||||
|
||||
writeStr(0, 'RIFF')
|
||||
view.setUint32(4, 36 + pcmBytes.length, true)
|
||||
writeStr(8, 'WAVE')
|
||||
|
||||
writeStr(12, 'fmt ')
|
||||
view.setUint32(16, 16, true)
|
||||
view.setUint16(20, 1, true)
|
||||
view.setUint16(22, numChannels, true)
|
||||
view.setUint32(24, sampleRate, true)
|
||||
view.setUint32(28, byteRate, true)
|
||||
view.setUint16(32, blockAlign, true)
|
||||
view.setUint16(34, bitsPerSample, true)
|
||||
|
||||
writeStr(36, 'data')
|
||||
view.setUint32(40, pcmBytes.length, true)
|
||||
|
||||
const wav = new Uint8Array(44 + pcmBytes.length)
|
||||
wav.set(new Uint8Array(header), 0)
|
||||
wav.set(pcmBytes, 44)
|
||||
return wav
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom fetch adapter that translates an OpenAI-compatible TTS request
|
||||
* into an OpenRouter streaming chat-completion with audio modality,
|
||||
@@ -167,9 +133,9 @@ function createAudioFetch(apiKey: string, baseUrl: string, model: string) {
|
||||
|
||||
const audioChunks = await collectAudioChunksFromSSE(sseResponse.body!)
|
||||
const pcmBytes = decodeBase64PCM(audioChunks)
|
||||
const wavBytes = wrapPCM16InWAV(pcmBytes)
|
||||
const wavBuffer = toWavFromPCM16(pcmBytes, 24000)
|
||||
|
||||
return new Response(new Blob([wavBytes.buffer as ArrayBuffer], { type: 'audio/wav' }), {
|
||||
return new Response(new Blob([wavBuffer], { type: 'audio/wav' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'audio/wav' },
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user