| import { APP_CONFIG, getFinnishUrl } from './config' |
|
|
| function interleaveMonoPcm16(samples: Float32Array): Int16Array { |
| const pcm = new Int16Array(samples.length) |
| for (let index = 0; index < samples.length; index += 1) { |
| const value = Math.max(-1, Math.min(1, samples[index])) |
| pcm[index] = value < 0 ? value * 0x8000 : value * 0x7fff |
| } |
| return pcm |
| } |
|
|
| export function createWavBlob(samples: Float32Array, sampleRate: number): Blob { |
| const pcm = interleaveMonoPcm16(samples) |
| const headerSize = 44 |
| const buffer = new ArrayBuffer(headerSize + pcm.byteLength) |
| const view = new DataView(buffer) |
|
|
| const writeString = (offset: number, value: string) => { |
| for (let index = 0; index < value.length; index += 1) { |
| view.setUint8(offset + index, value.charCodeAt(index)) |
| } |
| } |
|
|
| writeString(0, 'RIFF') |
| view.setUint32(4, 36 + pcm.byteLength, true) |
| writeString(8, 'WAVE') |
| writeString(12, 'fmt ') |
| view.setUint32(16, 16, true) |
| view.setUint16(20, 1, true) |
| view.setUint16(22, 1, true) |
| view.setUint32(24, sampleRate, true) |
| view.setUint32(28, sampleRate * 2, true) |
| view.setUint16(32, 2, true) |
| view.setUint16(34, 16, true) |
| writeString(36, 'data') |
| view.setUint32(40, pcm.byteLength, true) |
|
|
| new Int16Array(buffer, headerSize).set(pcm) |
| return new Blob([buffer], { type: 'audio/wav' }) |
| } |
|
|
| async function decodeAudioBuffer(data: ArrayBuffer): Promise<AudioBuffer> { |
| const context = new AudioContext() |
| try { |
| return await context.decodeAudioData(data.slice(0)) |
| } finally { |
| await context.close() |
| } |
| } |
|
|
| async function resampleMono(audioBuffer: AudioBuffer, sampleRate: number): Promise<Float32Array> { |
| const offline = new OfflineAudioContext( |
| 1, |
| Math.ceil(audioBuffer.duration * sampleRate), |
| sampleRate, |
| ) |
| const source = offline.createBufferSource() |
| const mono = offline.createBuffer(1, audioBuffer.length, audioBuffer.sampleRate) |
| const left = audioBuffer.getChannelData(0) |
| if (audioBuffer.numberOfChannels === 1) { |
| mono.copyToChannel(left, 0) |
| } else { |
| const mixed = mono.getChannelData(0) |
| for (let index = 0; index < mixed.length; index += 1) { |
| let sum = 0 |
| for (let channel = 0; channel < audioBuffer.numberOfChannels; channel += 1) { |
| sum += audioBuffer.getChannelData(channel)[index] |
| } |
| mixed[index] = sum / audioBuffer.numberOfChannels |
| } |
| } |
|
|
| source.buffer = mono |
| source.connect(offline.destination) |
| source.start() |
| const rendered = await offline.startRendering() |
| return rendered.getChannelData(0).slice() |
| } |
|
|
| export async function loadReferenceAudio(): Promise<Float32Array> { |
| const response = await fetch(getFinnishUrl(APP_CONFIG.paths.referenceAudio)) |
| if (!response.ok) { |
| throw new Error(`Failed to load reference audio: ${response.status}`) |
| } |
| const decoded = await decodeAudioBuffer(await response.arrayBuffer()) |
| return resampleMono(decoded, APP_CONFIG.sampleRate) |
| } |
|
|