Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
54d761463This file is pinned to the selected release. Download the original to open it in your design tool.
import assert from 'node:assert/strict'
import { test } from 'node:test'
import {
renderStackchanVoiceKoeWav,
renderStackchanVoiceWav,
STACKCHAN_VOICE_OUTPUT_SAMPLE_RATE,
type StackchanVoiceRenderer,
} from '../wasm/stackchan-voice-wav.js'
class FakeStackchanVoice implements StackchanVoiceRenderer {
readonly koeCalls: Array<{ koe: string; speed?: number }> = []
readonly sayCalls: Array<{ text: string; speed?: number }> = []
#offset = 0
constructor(readonly samples: Int16Array) {}
say(text: string, speed?: number): void {
this.sayCalls.push({ text, speed })
this.#offset = 0
}
koe(koe: string, speed?: number): void {
this.koeCalls.push({ koe, speed })
this.#offset = 0
}
read24(buffer: ArrayBuffer): number {
if (this.#offset >= this.samples.length) return 0
const output = new Int16Array(buffer)
const count = Math.min(output.length, this.samples.length - this.#offset)
output.set(this.samples.subarray(this.#offset, this.#offset + count))
this.#offset += count
return count
}
}
function ascii(buffer: ArrayBuffer, offset: number, length: number): string {
return String.fromCharCode(...new Uint8Array(buffer, offset, length))
}
test('renderStackchanVoiceWav renders 24 kHz mono PCM with volume and a valid WAV header', async () => {
const voice = new FakeStackchanVoice(new Int16Array([1000, -1000, 2000]))
const rendered = await renderStackchanVoiceWav(voice, 'こんにちは', {
chunkSamples: 2,
schedule: queueMicrotask,
speed: 120,
volume: 0.5,
})
const header = new DataView(rendered.buffer)
const pcm = new Int16Array(rendered.buffer, 44)
assert.deepEqual(voice.sayCalls, [{ text: 'こんにちは', speed: 120 }])
assert.equal(ascii(rendered.buffer, 0, 4), 'RIFF')
assert.equal(ascii(rendered.buffer, 8, 4), 'WAVE')
assert.equal(ascii(rendered.buffer, 12, 4), 'fmt ')
assert.equal(ascii(rendered.buffer, 36, 4), 'data')
assert.equal(header.getUint16(20, true), 1)
assert.equal(header.getUint16(22, true), 1)
assert.equal(header.getUint32(24, true), STACKCHAN_VOICE_OUTPUT_SAMPLE_RATE)
assert.equal(header.getUint16(34, true), 16)
assert.equal(header.getUint32(40, true), 6)
assert.deepEqual([...pcm], [500, -500, 1000])
assert.equal(rendered.samples, 3)
assert.equal(rendered.power, Math.sqrt((500 ** 2 + 500 ** 2 + 1000 ** 2) / 3))
})
test('renderStackchanVoiceWav grows its PCM buffer and clamps volume', async () => {
const samples = Int16Array.from({ length: 6000 }, (_, index) => (index % 2 === 0 ? 20000 : -20000))
const voice = new FakeStackchanVoice(samples)
const rendered = await renderStackchanVoiceWav(voice, '長い文章', {
chunkSamples: 257,
schedule: queueMicrotask,
volume: 2,
})
assert.equal(rendered.samples, samples.length)
assert.equal(rendered.buffer.byteLength, 44 + samples.byteLength)
assert.deepEqual([...new Int16Array(rendered.buffer, 44, 4)], [20000, -20000, 20000, -20000])
})
test('renderStackchanVoiceKoeWav starts the renderer with singing koe notation', async () => {
const voice = new FakeStackchanVoice(new Int16Array([500, -500]))
const rendered = await renderStackchanVoiceKoeWav(voice, '#C4,500ki#D4,500ra', {
chunkSamples: 2,
schedule: queueMicrotask,
speed: 90,
})
assert.deepEqual(voice.koeCalls, [{ koe: '#C4,500ki#D4,500ra', speed: 90 }])
assert.deepEqual(voice.sayCalls, [])
assert.equal(rendered.samples, 2)
})
test('renderStackchanVoiceWav emits an empty but valid WAV for an empty utterance', async () => {
const rendered = await renderStackchanVoiceWav(new FakeStackchanVoice(new Int16Array()), '', {
schedule: queueMicrotask,
volume: -1,
})
assert.equal(rendered.samples, 0)
assert.equal(rendered.power, 0)
assert.equal(rendered.buffer.byteLength, 44)
assert.equal(new DataView(rendered.buffer).getUint32(40, true), 0)
})
test('renderStackchanVoiceWav yields before and between native chunks', async () => {
const voice = new FakeStackchanVoice(new Int16Array([1, 2, 3]))
const tasks: Array<() => void> = []
const renderedPromise = renderStackchanVoiceWav(voice, 'scheduled', {
chunkSamples: 2,
schedule: (task) => tasks.push(task),
})
assert.equal(voice.sayCalls.length, 0)
assert.equal(tasks.length, 1)
while (tasks.length > 0) tasks.shift()?.()
const rendered = await renderedPromise
assert.equal(rendered.samples, 3)
assert.deepEqual(voice.sayCalls, [{ text: 'scheduled', speed: 100 }])
})
test('renderStackchanVoiceWav rejects a renderer that exceeds the sample bound', async () => {
const endlessVoice: StackchanVoiceRenderer = {
koe: () => {},
say: () => {},
read24: (buffer) => {
new Int16Array(buffer).fill(1)
return new Int16Array(buffer).length
},
}
await assert.rejects(
renderStackchanVoiceWav(endlessVoice, 'endless', {
chunkSamples: 4,
maxSamples: 5,
schedule: queueMicrotask,
}),
/maximum of 5 samples/,
)
})