diff --git a/packages/audio/package.json b/packages/audio/package.json index 7a576a981..e100f9c2e 100644 --- a/packages/audio/package.json +++ b/packages/audio/package.json @@ -42,7 +42,8 @@ "scripts": { "dev": "pnpm run build", "build": "tsdown", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test:run": "vitest run" }, "peerDependencies": { "vue": ">=3" diff --git a/packages/audio/src/encoding/wav.test.ts b/packages/audio/src/encoding/wav.test.ts new file mode 100644 index 000000000..3417b3bbc --- /dev/null +++ b/packages/audio/src/encoding/wav.test.ts @@ -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]) + }) +}) diff --git a/packages/audio/src/encoding/wav.ts b/packages/audio/src/encoding/wav.ts index 6d955f9d0..6e1567d56 100644 --- a/packages/audio/src/encoding/wav.ts +++ b/packages/audio/src/encoding/wav.ts @@ -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)) } diff --git a/packages/audio/vitest.config.ts b/packages/audio/vitest.config.ts new file mode 100644 index 000000000..647f39364 --- /dev/null +++ b/packages/audio/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + }, +}) diff --git a/packages/stage-ui/src/stores/providers/google-gemini-speech.ts b/packages/stage-ui/src/stores/providers/google-gemini-speech.ts index 510c4d566..1649e8f27 100644 --- a/packages/stage-ui/src/stores/providers/google-gemini-speech.ts +++ b/packages/stage-ui/src/stores/providers/google-gemini-speech.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' }, }) diff --git a/packages/stage-ui/src/stores/providers/openai-compatible-builder.ts b/packages/stage-ui/src/stores/providers/openai-compatible-builder.ts index a75ea690d..a28eecae2 100644 --- a/packages/stage-ui/src/stores/providers/openai-compatible-builder.ts +++ b/packages/stage-ui/src/stores/providers/openai-compatible-builder.ts @@ -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 diff --git a/packages/stage-ui/src/stores/providers/openrouter/audio-speech.ts b/packages/stage-ui/src/stores/providers/openrouter/audio-speech.ts index ad1b7f22b..3f1e1f46c 100644 --- a/packages/stage-ui/src/stores/providers/openrouter/audio-speech.ts +++ b/packages/stage-ui/src/stores/providers/openrouter/audio-speech.ts @@ -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' }, })