4

For quite a long time I wanted to add TTS (text-to-speech) to my MCU applications and I tried quite few of them with more or less success always hitting a wall that either quality is not good or needed CPU power is too much.

However I recently found a very old TTS from ZX Spectrum (in the link is more info and also link to original tap file repository) that is really good and simple (just 801 Bytes of Z80 asm code). So I did it a try , disassembled it (extract the basic and asm from tap file by my own utilities and disassembled with YAZD) and port the result to C++ with complete success. It sound good on both PC and MCU with very little CPU power needed. It produces 1 bit digital sound.

Here is the C++ source code I made:

//---------------------------------------------------------------------------
//---  ZX Hlasovy program voicesoft 1985  -----------------------------------    
//--- ported to C++ by Spektre ver: 1.001 -----------------------------------
//---------------------------------------------------------------------------
#ifndef _speech_h
#define _speech_h
//---------------------------------------------------------------------------
// API:
void sound_out(bool on);    // you need to code this function (should add a sample to sound output)
void say_text(char *txt);   // say null terminated text, "a'c'" -> "áè"
//---------------------------------------------------------------------------
// internals:
void say_char(char chr);    // internal function for single character (do not use it !!!)
void say_wait(WORD ws);     // internal wait (do not use it !!!)
//---------------------------------------------------------------------------
// vars:
bool _sound_on=false;       // global state of the reproductor/sound output
//---------------------------------------------------------------------------
// config: (recomputed for 44100 Hz samplerate)
const static BYTE t_speed=5;        // [samples] 1/(speech speed) (pitch)
const static WORD t_pause=183;      // [samples] pause between chars
const static WORD t_space=2925;     // [samples] pause ` `
const static WORD t_comma=5851;     // [samples] pause `,`
//---------------------------------------------------------------------------
// tables:
const static BYTE tab_char0[52]=    //  0..25 normal alphabet A..Z
    {                               // 26..51 diacritic alphabet A..Z
    0x00,0x02,0x06,0x0a,0x0e,0x10,0x12,0x16,0x1a,0x1c,0x22,0x26,0x2a,0x2e,0x32,
    0x34,0x38,0x42,0x48,0x4a,0x4e,0x50,0x50,0x56,0x1a,0x5c,0x64,0x66,0x70,0x74,
    0x7a,0x7c,0xc2,0x84,0x86,0xc2,0xc2,0xc2,0x88,0x8c,0x92,0x94,0xc2,0x9e,0xa6,
    0xa8,0xae,0xb0,0xc2,0xc2,0x86,0xbc
    };
const static BYTE tab_char1[196]=
    {
    0x36,0x81,0x34,0x19,0x31,0xab,0x18,0x19,0x91,0xc3,0x34,0x19,0x31,0xe0,0x36,
    0x84,0x92,0xe3,0x35,0x19,0x51,0x9c,0x31,0x31,0x34,0x96,0x36,0x87,0x33,0x3a,
    0x32,0x3d,0x32,0xc0,0x18,0x19,0x51,0x9c,0x33,0x22,0x31,0xb1,0x31,0x31,0x36,
    0xa5,0x31,0x31,0x36,0xa8,0x36,0x8a,0x18,0x19,0x31,0xab,0x18,0x19,0x51,0x1c,
    0x34,0x31,0x32,0x34,0x32,0xb7,0x22,0x10,0x13,0x19,0x21,0xae,0x92,0xc3,0x18,
    0x19,0x31,0xe0,0x36,0x8d,0x34,0x31,0x32,0x34,0x32,0xb7,0x18,0x19,0x71,0x1c,
    0x92,0xc3,0x32,0x31,0x32,0x43,0x32,0x44,0x32,0xc5,0x3f,0x81,0x34,0x19,0x31,
    0x2b,0x33,0x3a,0x32,0x3d,0x32,0xc0,0x18,0x19,0x91,0xd3,0x33,0x19,0x71,0x6d,
    0x32,0x93,0x3e,0x84,0x92,0x63,0x33,0x3a,0x32,0x3d,0x32,0xc0,0x92,0xf3,0x3e,
    0x87,0x31,0x31,0x36,0x25,0x31,0x31,0x35,0x25,0x32,0x93,0x3e,0x8a,0x18,0x19,
    0x31,0x2b,0x33,0x3a,0x32,0x3d,0x32,0xc0,0x13,0x19,0x32,0x60,0x13,0x19,0x71,
    0xdd,0x92,0xd3,0x18,0x19,0x71,0x6d,0x32,0x93,0x3e,0x8d,0x34,0x31,0x32,0x34,
    0x32,0x37,0x33,0x3a,0x32,0x3d,0x32,0xc0,0x32,0x53,0x32,0x54,0x32,0xd5,0x1a,
    0x99
    };
const static BYTE tab_char2[262]=
    {
    0x1a,0x99,0xe1,0xc3,0xe1,0xc7,0x8f,0x0f,0xf8,0x03,0x0f,0x07,0xc1,0xe3,0xff,
    0x40,0x17,0xff,0x00,0x03,0xf8,0x7c,0xc1,0xf1,0xf8,0x03,0xfe,0x00,0x7f,0xfc,
    0x00,0x03,0xf8,0x0f,0x09,0xf1,0xfe,0x03,0xef,0x40,0x17,0xff,0x00,0x03,0xe1,
    0x5c,0x35,0xc5,0xaa,0x35,0x00,0x00,0x00,0x00,0x00,0x00,0x3e,0x8e,0x38,0x73,
    0xcf,0xf8,0x78,0xc3,0xdf,0x1c,0xf1,0xc7,0xfe,0x03,0xc0,0xff,0x00,0x00,0xff,
    0xf8,0x00,0x7f,0xf8,0x03,0xff,0xf0,0x01,0xff,0xe0,0x03,0xaa,0xca,0x5a,0xd5,
    0x21,0x3d,0xfe,0x1f,0xf8,0x00,0x00,0x1f,0xff,0xfc,0x20,0x00,0x00,0x03,0xff,
    0xff,0x08,0x79,0x00,0x02,0xff,0xe1,0xc7,0x1f,0xe0,0x03,0xff,0xd0,0x01,0xff,
    0xf0,0x03,0x7f,0x01,0xfa,0x5f,0xc0,0x07,0xf8,0x0f,0xc0,0xff,0x00,0x42,0xaa,
    0xa5,0x55,0x5a,0xaa,0xaa,0x5a,0xa5,0x5a,0xaa,0x55,0x55,0xaa,0xaa,0xa5,0x55,
    0xaa,0x5a,0xaa,0xa5,0x55,0xaa,0xaa,0xa5,0x55,0xaa,0xaa,0x55,0xa5,0xa5,0xaa,
    0xa5,0xb7,0x66,0x6c,0xd8,0xf9,0xb3,0x6c,0xad,0x37,0x37,0x66,0xfc,0x9b,0x87,
    0xf6,0xc0,0xd3,0xb6,0x60,0xf7,0xf7,0x3e,0x4d,0xfb,0xfe,0x5d,0xb7,0xde,0x46,
    0xf6,0x96,0xb4,0x4f,0xaa,0xa9,0x55,0xaa,0xaa,0xa5,0x69,0x59,0x9a,0x6a,0x95,
    0x55,0x95,0x55,0x6a,0xa5,0x55,0xa9,0x4d,0x66,0x6a,0x92,0xec,0xa5,0x55,0xd2,
    0x96,0x55,0xa2,0xba,0xcd,0x00,0x66,0x99,0xcc,0x67,0x31,0x8e,0x66,0x39,0xa6,
    0x6b,0x19,0x66,0x59,0xc6,0x71,0x09,0x67,0x19,0xcb,0x01,0x71,0xcc,0x73,0x19,
    0x99,0xcc,0xc6,0x67,0x19,0x9a,0xc6,
    };
const static BYTE tab_char3[5]={ 0x00,0x2e,0x5a,0x5e,0xfe };
//---------------------------------------------------------------------------
void say_text(char *txt)
    {
    WORD hl;
    BYTE a,b,c;
    for (b=0xBB,hl=0;;hl++)     // process txt
        {
        a=b;                    // a,c char from last iteration
        c=b;
        if (!a) break;          // end of txt
        b=txt[hl];              // b actual char
        if ((b>='a')&&(b<='z')) b=b+'A'-'a'; // must be uppercase
        a=c;
        if ((a>='A')&&(a<='Z'))
            {
            // handle diacritic
            if (a!='C'){ a=b; if (a!='\'') a=c; else{ a=c; a+=0x1A; b=0xBB; }}
            else{
                a=b;
                if (a=='H'){ a+=0x1A; b=0xBB; }
                 else{ if (a!='\'') a=c; else{ a=c; a+=0x1A; b=0xBB; }}
                }
            // syntetize sound
            say_char(a);
            continue;
            }
        if (a==',')say_wait(t_comma);
        if (a==' ')say_wait(t_space);
        }
    }
//----------------------------------------------------------------------
void say_wait(WORD ws)
    {
    for (;ws;ws--) sound_out(_sound_on);
    }
//----------------------------------------------------------------------
void say_char(char chr) // chr =  < `A` , `Z`+26 >
    {
    WORD hl,hl0;
    BYTE a,b,c,cy,cy0,ws;
    hl=tab_char0[chr-'A'];
    for (;;)
        {
        c =tab_char1[hl  ]&0x0F;
        c|=tab_char1[hl+1]&0x80;
        for (;;)
            {
            a=tab_char1[hl];
            a=(a>>5)&7;
            cy=a&1;
            hl0=hl;
            if (a!=0)
                {
                b=tab_char3[a];
                hl=hl0;
                a=tab_char1[hl+1];
                hl0=hl;
                cy0=(a>>7)&1;
                a=((a<<1)&254)|cy;
                cy=cy0;
                hl=a;
                a=0x80;
                for (;;)
                    {
                    _sound_on=(a&tab_char2[hl]);
                    for (ws=t_speed;ws;ws--) sound_out(_sound_on);
                    b--;
                    if (!b) break;
                    cy=a&1;     
                    a=((a>>1)&127)|(cy<<7);
                    if (!cy) continue;
                    hl++;
                    }
                }
            a^=a;
            say_wait(t_pause);
            c--;
            a=c&0x0F;
            hl=hl0; 
            if (a==0) break;
            }
        cy0=(c>>7)&1;
        a=((c<<1)&254)|cy;
        cy=cy0;
        if (cy) return;
        hl+=2;
        }
    }
//---------------------------------------------------------------------------
#endif
//---------------------------------------------------------------------------

This works perfectly however I would like to understand how the sound is synthetized. I can not make any sense of it... is it some sort of compression of samples or uses formant filter to synthetize sound or combines them or its something else?

So I want to dissect the say_char function to make sense/meaning of the tab_char?[] LUT tables.

[Edit2] thanks to Edward new more C/C++ like version

I rearranged the tables and added a lot of comment info to be more didactical and possible to tweak:

//---------------------------------------------------------------------------
//---  ZX Hlasovy program voicesoft 1985  -----------------------------------
//--- ported to C++ by Spektre ver: 2.001 -----------------------------------
//---------------------------------------------------------------------------
#ifndef _speech_h
#define _speech_h
//---------------------------------------------------------------------------
// API:
void sound_out(bool on);    // you need to code this function (should add a sample to sound output)
void say_text(char *txt);   // say null terminated text, "a'c'" -> "áč"
//---------------------------------------------------------------------------
// internals:
void say_char(char chr);    // internal function for single character (do not use it !!!)
void say_wait(WORD ws);     // internal wait (do not use it !!!)
//---------------------------------------------------------------------------
// vars:
bool _sound_on=false;       // global state of the reproductor/sound output
//---------------------------------------------------------------------------
// config: (recomputed for 44100 Hz samplerate)
const static BYTE t_speed=5;        // [samples] 1/(speech speed) (pitch)
const static WORD t_pause=183;      // [samples] pause between chars
const static WORD t_space=2925;     // [samples] pause ` `
const static WORD t_comma=5851;     // [samples] pause `,`
//---------------------------------------------------------------------------
// point to RLE encoded character sound (RLE_ix)
const static BYTE tab_char[52]=
    {
//   A  B  C  D  E  F  G  H  I  J  K  L  M  N  O  P  Q  R  S  T  U  V  W  X  Y  Z
     0, 1, 3, 5, 7, 8, 9,11,13,14,17,19,21,23,25,26,28,33,36,37,39,40,40,43,13,46,
//   A' B' C' D' E' F' G' H' I' J' K' L' M' N' O' P' Q' R' S' T' U' V' W' X' Y' Z'
    50,51,56,58,61,62,97,66,67,97,97,97,68,70,73,74,97,79,83,84,87,88,97,97,67,94,
    };
// RLE encoded character sounds
const static WORD tab_RLE[98]=
    {
    //  15 14 13 12 11 10  9  8  7  6  5  4  3  2  1  0
    // end -----num------ ------------PCM_ix-----------
                                                // ix char
    0x9804,                                     //  0 A
    0x103D,0x8473,                              //  1 B
    0x203C,0x84AB,                              //  3 C
    0x103D,0x8524,                              //  5 D
    0x980B,                                     //  7 E
    0x892B,                                     //  8 F
    0x143D,0x8444,                              //  9 G
    0x0481,0x9035,                              // 11 H
    0x9812,                                     // 13 I,Y
    0x0C96,0x089D,0x88A4,                       // 14 J
    0x203C,0x8444,                              // 17 K
    0x0C5E,0x8481,                              // 19 L
    0x0481,0x9865,                              // 21 M
    0x0481,0x986C,                              // 23 N
    0x9819,                                     // 25 O
    0x203C,0x8473,                              // 26 P
    0x203C,0x0444,0x1081,0x0888,0x888F,         // 28 Q
    0x0827,0x0C3C,0x847A,                       // 33 R
    0x88AB,                                     // 36 S
    0x203C,0x8524,                              // 37 T
    0x9820,                                     // 39 U
    0x1081,0x0888,0x888F,                       // 40 V,W
    0x203C,0x0451,0x88AB,                       // 43 X
    0x0881,0x08CC,0x08D3,0x88DA,                // 46 Z
    0xBC04,                                     // 50 A'
    0x103D,0x0473,0x0C96,0x089D,0x88A4,         // 51 B' *
    0x203C,0x84E1,                              // 56 C'
    0x0C3D,0x054C,0x882E,                       // 58 D'
    0xB80B,                                     // 61 E'
    0x092B,0x0C96,0x089D,0x88A4,                // 62 F' *
    0x8959,                                     // 66 CH,H'
    0xB812,                                     // 67 I',Y'
    0x0481,0x1865,                              // 68 M' overlap with N' *
                  0x0481,0x1465,0x882E,         // 70 N' overlap with M'
    0xB819,                                     // 73 O'
    0x203C,0x0473,0x0C96,0x089D,0x88A4,         // 74 P' *
    0x0C3C,0x0924,0x0C3C,0x8517,                // 79 R'
    0x88E1,                                     // 83 S'
    0x203C,0x054C,0x882E,                       // 84 T'
    0xB820,                                     // 87 U'
    0x1081,0x0888,0x088F,0x0C96,0x089D,0x88A4,  // 88 V',W' *
    0x0902,0x0909,0x8910,                       // 94 Z'
    0xA83C,                                     // 97 G',J',K',L',Q',X',W' (no sound)
    // missing: Ľ/Ĺ,Ř/Ŕ,Ú/ˇU,ô,ä,é/ě
    // accent?: B',F',M',P',V'
    // nosound: G',J',K',L',Q',X',W'
    };
// formant sounds sampled as 1bit PCM
const static BYTE tab_PCM[]=
    {
// bits,1bit PCM samples                            //  ix,sample in binary
     24,0x1A,0x99,0xE1,                             //   0,000110101001100111100001
     46,0xC3,0xE1,0xC7,0x8F,0x0F,0xF8,              //   4,110000111110000111000111100011110000111111111000
     46,0x03,0x0F,0x07,0xC1,0xE3,0xFF,              //  11,000000110000111100000111110000011110001111111111
     46,0x40,0x17,0xFF,0x00,0x03,0xF8,              //  18,010000000001011111111111000000000000001111111000
     46,0x7C,0xC1,0xF1,0xF8,0x03,0xFE,              //  25,011111001100000111110001111110000000001111111110
     46,0x00,0x7F,0xFC,0x00,0x03,0xF8,              //  32,000000000111111111111100000000000000001111111000
     46,0x0F,0x09,0xF1,0xFE,0x03,0xEF,              //  39,000011110000100111110001111111100000001111101111
     46,0x40,0x17,0xFF,0x00,0x03,0xE1,              //  46,010000000001011111111111000000000000001111100001
     46,0x5C,0x35,0xC5,0xAA,0x35,0x00,              //  53,010111000011010111000101101010100011010100000000
      0,                                            //  60,
     46,0x00,0x00,0x00,0x00,0x00,0x3E,              //  61,000000000000000000000000000000000000000000111110
     90,0x3E,0x8E,0x38,0x73,0xCF,0xF8,0x78,0xC3,    //  68,0011111010001110001110000111001111001111111110000111100011000011
        0xDF,0x1C,0xF1,0xC7,                        //     11011111000111001111000111000111
     94,0x8E,0x38,0x73,0xCF,0xF8,0x78,0xC3,0xDF,    //  81,1000111000111000011100111100111111111000011110001100001111011111
        0x1C,0xF1,0xC7,0xFE,                        //     00011100111100011100011111111110
     46,0x03,0xC0,0xFF,0x00,0x00,0xFF,              //  94,000000111100000011111111000000000000000011111111
     46,0xF8,0x00,0x7F,0xF8,0x03,0xFF,              // 101,111110000000000001111111111110000000001111111111
     46,0xF0,0x01,0xFF,0xE0,0x03,0xAA,              // 108,111100000000000111111111111000000000001110101010
     46,0xCA,0x5A,0xD5,0x21,0x3D,0xFE,              // 115,110010100101101011010101001000010011110111111110
     46,0x1F,0xF8,0x00,0x00,0x1F,0xFF,              // 122,000111111111100000000000000000000001111111111111
     46,0xFC,0x20,0x00,0x00,0x03,0xFF,              // 129,111111000010000000000000000000000000001111111111
     46,0xFF,0x08,0x79,0x00,0x02,0xFF,              // 136,111111110000100001111001000000000000001011111111
     46,0xE1,0xC7,0x1F,0xE0,0x03,0xFF,              // 143,111000011100011100011111111000000000001111111111
     46,0xD0,0x01,0xFF,0xF0,0x03,0x7F,              // 150,110100000000000111111111111100000000001101111111
     46,0x01,0xFA,0x5F,0xC0,0x07,0xF8,              // 157,000000011111101001011111110000000000011111111000
     46,0x0F,0xC0,0xFF,0x00,0x42,0xAA,              // 164,000011111100000011111111000000000100001010101010
    254,0xAA,0xA5,0x55,0x5A,0xAA,0xAA,0x5A,0xA5,    // 171,1010101010100101010101010101101010101010101010100101101010100101
        0x5A,0xAA,0x55,0x55,0xAA,0xAA,0xA5,0x55,    //     0101101010101010010101010101010110101010101010101010010101010101
        0xAA,0x5A,0xAA,0xA5,0x55,0xAA,0xAA,0xA5,    //     1010101001011010101010101010010101010101101010101010101010100101
        0x55,0xAA,0xAA,0x55,0xA5,0xA5,0xAA,0xA5,    //     0101010110101010101010100101010110100101101001011010101010100101
     46,0xA5,0x55,0x5A,0xAA,0xAA,0x5A,              // 204,101001010101010101011010101010101010101001011010
     46,0x5A,0xAA,0xAA,0x5A,0xA5,0x5A,              // 211,010110101010101010101010010110101010010101011010
     46,0xAA,0x5A,0xA5,0x5A,0xAA,0x55,              // 218,101010100101101010100101010110101010101001010101
    254,0xB7,0x66,0x6C,0xD8,0xF9,0xB3,0x6C,0xAD,    // 225,1011011101100110011011001101100011111001101100110110110010101101
        0x37,0x37,0x66,0xFC,0x9B,0x87,0xF6,0xC0,    //     0011011100110111011001101111110010011011100001111111011011000000
        0xD3,0xB6,0x60,0xF7,0xF7,0x3E,0x4D,0xFB,    //     1101001110110110011000001111011111110111001111100100110111111011
        0xFE,0x5D,0xB7,0xDE,0x46,0xF6,0x96,0xB4,    //     1111111001011101101101111101111001000110111101101001011010110100
     46,0x66,0x6C,0xD8,0xF9,0xB3,0x6C,              // 258,011001100110110011011000111110011011001101101100
     46,0xD8,0xF9,0xB3,0x6C,0xAD,0x37,              // 265,110110001111100110110011011011001010110100110111
     46,0xB3,0x6C,0xAD,0x37,0x37,0x66,              // 272,101100110110110010101101001101110011011101100110
     94,0x3E,0x4D,0xFB,0xFE,0x5D,0xB7,0xDE,0x46,    // 279,0011111001001101111110111111111001011101101101111101111001000110
        0xF6,0x96,0xB4,0x4F,                        //     11110110100101101011010001001111
     46,0xDE,0x46,0xF6,0x96,0xB4,0x4F,              // 292,110111100100011011110110100101101011010001001111
    254,0x4F,0xAA,0xA9,0x55,0xAA,0xAA,0xA5,0x69,    // 299,0100111110101010101010010101010110101010101010101010010101101001
        0x59,0x9A,0x6A,0x95,0x55,0x95,0x55,0x6A,    //     0101100110011010011010101001010101010101100101010101010101101010
        0xA5,0x55,0xA9,0x4D,0x66,0x6A,0x92,0xEC,    //     1010010101010101101010010100110101100110011010101001001011101100
        0xA5,0x55,0xD2,0x96,0x55,0xA2,0xBA,0xCD,    //     1010010101010101110100101001011001010101101000101011101011001101
     94,0x6A,0x92,0xEC,0xA5,0x55,0xD2,0x96,0x55,    // 332,0110101010010010111011001010010101010101110100101001011001010101
        0xA2,0xBA,0xCD,0x00,                        //     10100010101110101100110100000000
    254,0x00,0x66,0x99,0xCC,0x67,0x31,0x8E,0x66,    // 345,0000000001100110100110011100110001100111001100011000111001100110
        0x39,0xA6,0x6B,0x19,0x66,0x59,0xC6,0x71,    //     0011100110100110011010110001100101100110010110011100011001110001
        0x09,0x67,0x19,0xCB,0x01,0x71,0xCC,0x73,    //     0000100101100111000110011100101100000001011100011100110001110011
        0x19,0x99,0xCC,0xC6,0x67,0x19,0x9A,0xC6,    //     0001100110011001110011001100011001100111000110011001101011000110
    };
//---------------------------------------------------------------------------
void say_text(char *txt)
    {
    int i;
    char a0,a1;
    for (a1=0xBB,i=0;a1;i++)                            // process txt
        {
        a0=a1; a1=txt[i];                               // a0,a1 are last,actual char
        if ((a1>='a')&&(a1<='z')) a1+='A'-'a';          // a..z -> A..Z
        if ((a0=='C')&&(a1=='H')){ a0='H'; a1='\''; }   // CH -> H'
        if ((a0>='A')&&(a0<='Z'))
            {
            if (a1=='\''){ a0+=0x1A; a1=0xBB; }         // handle diacritic
            say_char(a0);                               // syntetize sound
            continue;
            }
        if (a0==',') say_wait(t_comma);
        if (a0==' ') say_wait(t_space);
        }
    }
//----------------------------------------------------------------------
void say_wait(WORD ws)
    {
    for (;ws;ws--) sound_out(_sound_on);
    }
//----------------------------------------------------------------------
void say_char(char chr) // chr =  < `A` , `Z`+26 >
    {
    WORD a;
    BYTE ws,pcm;
    int i,j,e,num,pcm_ix,bits;
    i=tab_char[chr-'A'];
    for (e=1;e;i++)
        {
        a=tab_RLE[i];
        e     =!(a     &0x8000);
        num   = (a>>10)&0x001F;
        pcm_ix=  a     &0x03FF;
        for (;num;num--)
            {
            for (j=pcm_ix,bits=tab_PCM[j],j++;bits;j++)
             for (pcm=tab_PCM[j],a=0x80;(bits)&&(a);a>>=1,bits--)
              for (_sound_on=(a&pcm),ws=t_speed;ws;ws--)
               sound_out(_sound_on);
            say_wait(t_pause);
            }
        }
    }
//---------------------------------------------------------------------------
#endif
//---------------------------------------------------------------------------
5

I can't really understand the speech from the Hlasový program at all, but perhaps it is suitable for your needs.

I don't have any specific knowledge of this particular software, but based on the time of release and the size, it's almost undoubtedly a formant-based system. The typical software (on the 8-bit computers of that vintage) used a text-to-phoneme and then phoneme-to-formant conversion.

A somewhat larger but more intelligible system from that era was "S.A.M." or "Software Automated Mouth" that someone has now ported to Javascript. Follow the links from there to read more, including reverse-engineered C code.

The author of that software from the early 1980s, Mark Barton, was actually recently interviewed and offers some insights into that software.

This program

Here's a further analysis of your reverse-engineered software. I'll tell you how I did it as well as showing the result. First, I started looking at the inner-most loop and successively rewrote it, testing the result each time to make sure it produced identical results at each step. Then I essentially repeated that for larger and larger portions of the function. I also renamed and added variables to make them better reflect how the software is actually using them. While the Z80 is limited in the registers it can use (and what those registers can do) we do not have that same limitation in C++, so the code is rewritten for clarity.

say_char()

void say_char(char chr)         // chr =  < `A` , `Z`+26 >
{
    const Chain *chain = &chain_sequence[chain_start[chr - 'A']];
    for (BYTE c=0; (c & 0x80) == 0; ++chain) {
        // count is in low four bits of c, end flag is high bit
        for (c = chain->copies_and_end(); c & 0xf; --c) {
            BYTE a = chain->numbits_lookup();
            if (a != 0) {
                BYTE bitcount = num_bits[a];
                BYTE bitloc = chain->start_index();

                // bitcount is the number of bits to emit
                // starting with the MSB of sound_bits[bitloc]
                for ( ;bitcount; ++bitloc) {
                    for (BYTE mask = 0x80; mask; mask >>= 1) {
                        _sound_on = (mask & sound_bits[bitloc]);
                        for (BYTE ws = t_speed; ws; ws--)
                            sound_out(_sound_on);
                        if (--bitcount == 0)
                            break;
                    }
                }
            }
            say_wait(t_pause);
        }
    }
}

Here's the explanation. First, I renamed the structures:

tab_char0 --> chain_start
tab_char1 --> chain_sequence
tab_char2 --> sound_bits
tab_char3 --> num_bits

Then I modified the chain_sequence to use a two-byte C++ structure instead. The definition is this:

struct Chain {
        // bits: 7    6    5    4    3    2    1    0
    BYTE a;  //  m2   m1   c0   -    l3   l2   l1   l0
    BYTE b;  // end | c7   c6   c5   c4   c3   c2   c1

    bool end() const { return b & 0x80; }
    BYTE copies() const { return a & 0x0F; }
    BYTE start_index() const { return ((b & 0x7f) << 1) | ((a & 0x20) >> 5); }
    BYTE copies_and_end() const {
        return (a & 0x0F) | (b & 0x80);
    }
    BYTE numbits_lookup() const {
        return (a >> 5) & 7;
    }
    friend std::ostream& operator<<(std::ostream& out, const Chain& ch) {
        return out 
            << "copies = " << unsigned(ch.copies())
            << ", start_index = " << unsigned(ch.start_index())
            << ", numbits_lookup = " << unsigned(ch.numbits_lookup())
            << ", end = " << std::boolalpha << bool(ch.b & 0x80)
            << ", useless = " << bool(ch.a & 0x10);
    }
};

Due to this change, I had to modify the chain_start table to halve each of the entries.

How it works

For each letter, the code starts with a lookup in the chain_start table. That is an index into the chain_sequence table. If we select the first three entries in that table, they look like this:

const static Chain chain_sequence[98] = {
    /* A = 0 */ { 0x36, 0x81, },
    /* B = 1 */ { 0x34, 0x19, }, { 0x31, 0xab, },
    /* C = 3 */ { 0x18, 0x19, }, { 0x91, 0xc3, },

Each of these is a chain sequence, with the last item identified with the high bit of the second byte set. For the letter 'A', it translates to this:

copies = 6, start_index = 3, numbits_lookup = 1, end = true 

What this then means is that the code creates six copies of a bit pattern. Each copy ends with t_pause zero bits. For the beginning bits of each copy, the code uses the numbits_lookup value to look up the desired length in the 5-byte num_bits. So for 'A', the lookup is 1 and that corresponds to 0x2e = 46, but the way the code is written, that actually corresponds to one fewer bits actually emitted, or 45 in this case.

Next it uses the start_index as the index into sound_bits. Each byte in the table is then clocked out starting with the most significant bit of each byte. So in this case, index 3 and a length of 45 bits corresponds to these entries in the table:

0xc3 0xe1 0xc7 0x8f, 0x0f, 0xf8

1100 0011  1110 0001  1100 0111  1000 1111  0000 1111  1111 10xx

The last two bits, marked xx are unused. So the effect of this is that the output corresponds to six copies of this:

1100001111100001110001111000111100001111111110
... followed by `t_pause` 0 bits

Commentary

Translation bug

There is a bug in the code. If you look closely, one of the bits in what I'm calling Chain is not used (bit 4 of the first byte), but one of the other bits is used twice (bit 5 of the first byte).

Indeed, I disassembled the original Z80 code and found this:

add hl,de       ; cy = 0 (can't overflow)
ld b,(hl)       ; b = bitlen[a];
pop hl          ;
inc hl          ;
ld a,(hl)       ; a = chain_sequence[hl + 1]
dec hl          ;
push hl         ;
rla             ; the carry shifted in is always zero
ld de,sound_bits    ; point to bit table
ld l,a          ;
ld h,000h       ;
add hl,de       ; hl = sound_bits[a]
ld a,080h       ; start with mask = 0x80

Your code seems to imply that the carry bit is set when calling what I've labeled start_index() and it is, but closer to the relevant rla instruction that creates the sound_bits index byte, the carry bit is guaranteed to be zero. The add instruction, as noted above, cannot overflow and so clears the carry bit. None of the instructions from there to the rla instruction alter the carry bit, so it is zero at that point.

Other observations

Also the first three bytes of the sound_bits array appear to be unused.

There doesn't appear to be a lot of overlapping data, but there could be. The chain sequence for one of the letters is re-used. I haven't worked on decoding the actual diacritics used here, but if the second 26 letters are designated A' to Z', the one for M' starts at index 68 and includes 5 chain segments. The one for N' uses the last three of these segments.

Also for short and long versions of the same vowel, such as A and A' (A with čárka signifies a long vowel in Czech), the current code repeats the chain token, but with just a longer sequence. It might be possible to combine them and use a single bit flag to indicate a vowel.

On a 16-bit machine, this could be made much more efficient by restructuring the data. It could also be modified to be event driven on an embedded system. For example, this could be interrupt-driven by a timer interrupt. Or one could create a queue of samples and use DMA transfer to clock them out to a speaker.

Physics

What this is doing is creating the lowest frequency via a sequence of bits (minimum of 45) followed by t_pause zeroes. The higher frequencies are created within the leading bit patterns in each copy. As expected, this a formant-based synthesizer with relatively low resolution.

6
  • nice links and yes those are standard formant using goniometrics... but the ZX TTS engines I saw use just bit masking, bitshifts and LUTs instead (not even integer arithmetic) nowhere resembling any goniometrics (they where done differently with arithmetics btw.) and I am interested on the sciense/tricks behind that ... maybe its some kind of simplification as the output is just 1 bit digital instead of DAC... btw the Hlasovy program is for slavic languages ... not very good for english unless you retype the suff phoneticaly (maybe that is why you did not understand it) – Spektre Dec 15 '20 at 19:44
  • If I have time, I'll take a deeper look and update my answer. – Edward Dec 15 '20 at 19:52
  • Its definatelly working like you are describing. I am in process of rewriting the tables and code to more C++ like and also to be tweakable... Once done I will add it to my quesiton. Btw looks like I was right its RLE compression of PCM sampled Formant base tones :) awesme work would upvote more if I could ... And I think youre right repairing the cy behavior for RLA sounded a bit better... I got the first 2 tables reorganized,... – Spektre Dec 17 '20 at 19:21
  • 1
    OK I added my version of re-coded speech following your understanding of the tables function. Thanks a lot for the hard-work. – Spektre Dec 17 '20 at 20:23
  • I updated the code a bit with complete rework of all tables, I also get rid of the bits table and disected the PCM samples alot ... – Spektre Dec 18 '20 at 12:47

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.