@picovoice/orca-node-demo
Version:
Picovoice Orca Node.js file-based and streaming demos
289 lines (255 loc) • 9.15 kB
JavaScript
//
// Copyright 2024-2025 Picovoice Inc.
//
// You may not use this file except in compliance with the license. A copy of the license is located in the "LICENSE"
// file accompanying this source.
//
// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
// an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
// specific language governing permissions and limitations under the License.
//
;
const os = require('os');
const { program } = require('commander');
const { performance } = require('perf_hooks');
const { execSync } = require('child_process');
const tiktoken = require('tiktoken');
const { PvSpeaker } = require('@picovoice/pvspeaker-node');
const { Orca, OrcaActivationLimitReachedError } = require('@picovoice/orca-node');
program
.requiredOption(
'-a, --access_key <string>',
'AccessKey obtain from the Picovoice Console (https://console.picovoice.ai/)',
)
.requiredOption(
'-t, --text_to_stream <string>',
'Text to be streamed to Orca',
)
.requiredOption(
'-m, --model_file_path <string>',
'Absolute path to Orca model',
)
.option(
'-l, --library_file_path <string>',
'Absolute path to dynamic library',
)
.option(
'--tokens_per_second <number>',
'Number of tokens per second to be streamed to Orca, simulating an LLM response',
'15',
)
.option(
'--audio_wait_chunks <number>',
'Number of PCM chunks to wait before starting to play audio',
)
.option(
'--buffer_size_secs <number>',
'The size in seconds of the internal buffer used by PvSpeaker to play audio',
'20',
)
.option(
'--audio_device_index <number>',
'Index of input audio device',
'-1',
)
.option(
'--show_audio_devices',
'Only list available audio output devices and exit',
);
if (process.argv.length < 3) {
program.help();
}
program.parse(process.argv);
function splitText(text, language) {
// TODO: Update once Orca supports passing in partial bytes
if (language === "ko" || language === "ja") {
return text.split("");
// TODO: Remove once tiktoken supports windows-arm64
} else if (os.platform() === 'win32' && os.arch() === 'arm64') {
const ALPHA_NUMERIC = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 '
const PUNCTUATION = '!"#$%&\'()*+,-./:;<=>?@[\]^_`{|}~ '
const tokensRaw = [text[0]]
for (let i = 1; i < text.length; i++) {
let ch = text[i];
let token = tokensRaw[tokensRaw.length - 1];
if ((ALPHA_NUMERIC.includes(ch) && !ALPHA_NUMERIC.includes(token[token.length - 1])) || PUNCTUATION.includes(ch)) {
tokensRaw.push(ch);
} else {
tokensRaw[tokensRaw.length - 1] += ch;
}
}
return tokensRaw;
} else {
const textDecoder = new TextDecoder();
const encoder = tiktoken.encoding_for_model('gpt-4');
const tokensRaw = Array.from(encoder.encode(text), e => textDecoder.decode(encoder.decode([e])));
encoder.free();
return tokensRaw;
}
}
function tokenizeText(text, language) {
const CUSTOM_PRON_PATTERN = /\{(.*?\|.*?)}/g;
const CUSTOM_PRON_PATTERN_NO_WHITESPACE = /\{(.*?\|.*?)}(?!\s)/g;
text = text.replace(CUSTOM_PRON_PATTERN_NO_WHITESPACE, '{$1} ');
let customPronunciations = text.match(CUSTOM_PRON_PATTERN) || [];
customPronunciations = new Set(customPronunciations);
const tokensRaw = splitText(text, language);
let customPron = '';
const tokensWithCustomPronunciations = [];
tokensRaw.forEach((token, i) => {
let inCustomPron = false;
customPronunciations.forEach(pron => {
const inCustomPronGlobal = customPron.length > 0;
const currentMatch = !inCustomPronGlobal ? token.trim() : customPron + token;
if (pron.startsWith(currentMatch)) {
customPron += !inCustomPronGlobal ? token.trim() : token;
inCustomPron = true;
}
});
if (!inCustomPron) {
if (customPron !== '') {
tokensWithCustomPronunciations.push(i !== 0 ? ` ${customPron}` : customPron);
customPron = '';
}
tokensWithCustomPronunciations.push(token);
}
});
return tokensWithCustomPronunciations;
}
function linuxMachine() {
const machine = os.arch();
let archInfo = '';
if (machine === 'x64') {
return machine;
} else if (['arm64', 'arm'].includes(machine)) {
if (os.arch() === 'x64') {
archInfo = `-${machine}`;
}
} else {
throw new Error(`Unsupported CPU architecture: ${machine}`);
}
let cpuInfo = '';
let cpuPart = '';
try {
cpuInfo = execSync('cat /proc/cpuinfo').toString();
const cpuPartList = cpuInfo.split('\n').filter(line => line.includes('CPU part'));
cpuPart = cpuPartList[0].split(' ').pop().toLowerCase();
} catch (e) {
throw new Error(`Failed to identify the CPU with ${e.message}\nCPU info: ${cpuInfo}`);
}
switch (cpuPart) {
case '0xd03':
return 'cortex-a53' + archInfo;
case '0xd08':
return 'cortex-a72' + archInfo;
case '0xd0b':
return 'cortex-a76' + archInfo;
default:
throw new Error(`Unsupported CPU: ${cpuPart}`);
}
}
function sleepSecs(ms) {
return new Promise(resolve => setTimeout(resolve, ms * 1000));
}
async function streamingDemo() {
let accessKey = program['access_key'];
let modelFilePath = program['model_file_path'];
let libraryFilePath = program['library_file_path'];
let text = program['text_to_stream'];
let tokensPerSeconds = program['tokens_per_second'];
let audioWaitChunks = program['audio_wait_chunks'];
let bufferSizeSecs = Number(program['buffer_size_secs']);
let deviceIndex = Number(program['audio_device_index']);
let showAudioDevices = program['show_audio_devices'];
const modelFilePrefix = "orca_params_";
const langCodeIdx = modelFilePath.indexOf(modelFilePrefix) + modelFilePrefix.length;
const language = modelFilePath.substring(langCodeIdx, langCodeIdx + 2);
if (showAudioDevices) {
const devices = PvSpeaker.getAvailableDevices();
for (let i = 0; i < devices.length; i++) {
console.log(`index: ${i}, device name: ${devices[i]}`);
}
return;
}
if (audioWaitChunks === undefined || audioWaitChunks === null) {
audioWaitChunks = 0;
if (os.platform() === 'linux') {
const machine = linuxMachine();
if (machine.includes('cortex')) {
audioWaitChunks = 1;
}
}
}
try {
const orca = new Orca(
accessKey,
{
'modelPath': modelFilePath,
'libraryPath': libraryFilePath,
},
);
console.log(`\nOrca version: ${orca.version}`);
const stream = orca.streamOpen();
let speaker = null;
try {
const bitsPerSample = 16;
speaker = new PvSpeaker(orca.sampleRate, bitsPerSample, { bufferSizeSecs, deviceIndex });
speaker.start();
} catch (e) {
console.error('\nNote: External package \'@picovoice/pvspeaker-node\' failed to initialize.' +
' Orca will generate the pcm, but it will not be played to your speakers.');
}
let pcmBuffer = [];
let numAudioChunks = 0;
let isStartedPlaying = false;
process.stdout.write('\nSimulated text stream: ');
let timeFirstAudioAvailable = null;
const tokens = tokenizeText(text, language);
const startTime = performance.now();
for (const token of tokens) {
process.stdout.write(token);
const pcm = stream.synthesize(token);
if (pcm !== null) {
if (timeFirstAudioAvailable === null) {
timeFirstAudioAvailable = ((performance.now() - startTime) / 1000).toFixed(2);
}
pcmBuffer.push(...pcm);
numAudioChunks++;
}
if (pcmBuffer.length > 0 && speaker !== null && (isStartedPlaying || numAudioChunks >= audioWaitChunks)) {
const arrayBuffer = new Int16Array(pcmBuffer).buffer;
const written = speaker.write(arrayBuffer);
pcmBuffer = pcmBuffer.slice(written);
isStartedPlaying = true;
}
await sleepSecs(1 / tokensPerSeconds);
}
const flushedPcm = stream.flush();
if (flushedPcm !== null) {
if (timeFirstAudioAvailable === null) {
timeFirstAudioAvailable = ((performance.now() - startTime) / 1000).toFixed(2);
}
pcmBuffer.push(...flushedPcm);
}
const elapsedTime = ((performance.now() - startTime) / 1000).toFixed(2);
console.log(`\n\nTime to finish text stream: ${elapsedTime} seconds`);
console.log(`Time to receive first audio: ${timeFirstAudioAvailable} seconds after text stream started`);
console.log('\nWaiting for audio to finish...');
if (speaker !== null) {
const arrayBuffer = new Int16Array(pcmBuffer).buffer;
speaker.flush(arrayBuffer);
speaker.stop();
speaker.release();
}
stream.close();
orca.release();
} catch (err) {
if (err instanceof OrcaActivationLimitReachedError) {
console.error(`AccessKey '${accessKey}' has reached it's processing limit.`);
} else {
console.error(err);
}
}
}
void streamingDemo();