4. The phonology engine

Na_VoiceSe — src/static/jaudio_NES/game/game64.c_inc:2598. Given three voice codes it produces one phoneme code now and optionally queues a second for the next frame.

void Na_VoiceSe(u8 a, u8 b, u8 c, s16 voice, u8 scale, u8 f)

f is the calling mode: 0 is the normal message path, 2 is the deferred second syllable, 1 is the editor path which skips this page entirely.

Vowel / consonant classification

Sou_BoinShiinCheck (:2564) — boin = vowel, shiin = consonant:

ReturnsClassMembers
0vowela e i o u and n
1consonantthe other 20 letters
2neitherdigits and every punctuation code

n being a vowel is deliberate — it is a syllabic nasal in Japanese, and this engine was written by Japanese programmers modelling English through a kana-shaped lens. It changes when the silent-e and magic-e rules fire, and it is the single most surprising thing in the engine.

Rule order for mode 0

State is carried in sou_last_num and sou_last_num2, the previous call's raw a and b. The originals are also stashed in sou_num_org/sou_num2_org/sou_num3_org before anything is rewritten.

0. Lone mute

if (a == 0x85 && b == 0xFF && c == 0xFF) return;

sou_num_org, sou_num2_org and sou_num3_org are assigned before this test, so they are overwritten; what survives is sou_last_num / sou_last_num2, which are written further down. A lone punctuation mark therefore leaves the digraph history intact, but still clobbers the context bytes a pending mode 2 call will read — sou_num3_org for its short-vowel lookup, sou_num_org for its dedup comparison.

1. Silent e (:2612)

if (a == 'e' && b > 0x77 && class(last_num) == vowel && class(last_num2) == consonant) {
    a = b;  r28 = 4;                      // drop the e, speak the punctuation slot
}
if (b == 'e' && c > 0x77 && class(last_num2) == vowel && class(a) == consonant) {
    b = 0;  r28 = 4;                      // drop the e, queue nothing
}

A word-final e preceded by vowel + consonant is dropped: make, time, nose. Because n classifies as a vowel, done and gone also trip it. Setting r28 = 4 skips the digraph pass entirely.

2. Digraph merge (:2623)

a = Sou_ConnectCheck(a, b, c);
r28 = (a == sou_num_org) ? 1 : 2;         // 2 = merged, both letters consumed

If a digraph matched, r28 = 2 and control falls straight through to playback: the merged phoneme plays and nothing is queued, so both letters are consumed by one sample.

The digraph table — Sou_ConnectCheck (:2316)

53 entries. All are two characters except you, the only rule that reads c; one pairs a letter with a space rather than another letter (i␣).

i␣ is very nearly unreachable. Its b is 0x81, which is also a punctuation-flush trigger — so rule 3 below sets r28 = 3 and unconditionally reassigns a, discarding the 0x05. It survives only when the previous call's b was also punctuation, which forces r28 = 1 instead; 0x05 then falls through Sou_TanboinHenkan's default: return a and reaches playback unchanged. Every other digraph has a letter in b and so never meets rule 3 — for those, "r28 = 2 falls straight through to playback" holds as written.
ai→0x0F
al→0x02
an→0x39
ar→0x16
as→0x3A
be→0x3B
by→0x3C
ch→0x08
do→0x3D
ea→0x15
er→0x0D
fa→0x35
fo→0x34
ge→0x17
go→0x3E
ha→0x3F
he→0x40
hi→0x41
ho→0x42
i␣→0x05
if→0x43
in→0x44
ir→0x0C
is→0x45
it→0x46
ks→0x31
me→0x47
my→0x48
na→0x38
ng→0x1C
no→0x49
of→0x4A
oh→0x4B
ok→0x4C
on→0x4D
oo→0x2A
or→0x2C
ou→0x20
ow→0x06
oy→0x1F
re→0x0E
se→0x37
sh→0x24
si→0x36
so→0x4F
su→0x25
th→0x28
to→0x50
up→0x51
we→0x52
wo→0x33
you→0x77
ze→0x32

The design is transparent. Fourteen entries are genuine English digraphs — ch sh th ng oo ow ou oy ai ea ar er ir or. The other thirty-nine are the openings of common English words: he ho hi ha me my no on of oh ok so to up we by do go is it in if as an al na se si su fa fo ge ze ks you. The engine does not model English phonetics; it memorises the most frequent English word-starts and gives each one a recorded syllable.

3. Punctuation flush (:2630)

if (b is punctuation) {
    r28 = 3;
    if (sou_last_num2 was also punctuation) r28 = 1;   // don't flush twice
}

When a word ends, r28 = 3 re-runs the merge one character further back:

a = Sou_ConnectCheck(sou_last_num2, sou_num_org, sou_num2_org);
if (a < 0x53 || a > 0x76) break;      // it merged, or wasn't a letter — leave it
a = Sou_TanboinHenkan(a, b);

The intent is to give the final letter of a word a chance to pair with the letter before it instead of being dropped against the following space. What it does is narrower. a is reloaded from sou_last_num2 — the previous call's b — and if Sou_ConnectCheck finds no merge it returns that byte unchanged, so Sou_TanboinHenkan speaks the previous letter a second time and the word-final letter is never voiced at all.

Worked example, cat␣. Window (c, a, t) doesn't merge, so r28 = 1 plays hard c and queues a; the deferred call plays a. Window (t, ␣, …) then hits rule 3, reloads a = sou_last_num2 = 'a', fails to merge at, and plays a again. The t makes no sound. The branch only pays off when the reloaded pair does merge — then the merged code plays and the 0x53–0x76 guard breaks out before Sou_TanboinHenkan can touch it.

Note the guard also drops the whole branch if a > 0x77 before it starts, and that nothing is queued on this path.

4. Vowel length (:2658, for r28 == 1 or 4)

if (class(a) == vowel && class(b) == consonant && c == 'e')
    a = Sou_ChouboinHenkan(a, b);      // long  — magic e, as in cake
else
    a = Sou_TanboinHenkan(a, b);       // short

sou_num2_request   = b;                // queue the second syllable
sou_num2_animal_id = voice;
sou_num2_scale     = scale;

Chouboin = long vowel, tanboin = short vowel. The two tables are identical except for five entries:

aiueon
short0x010x140x290x0A0x1D0x1B
long0x020x150x2A0x0E0x1E0x1B

n is the same in both — it is only classified as a vowel, it has no long form. Note the aliasing: long a (0x02) is the same sample as digraph al, long i (0x15) is ea and consonant y, long e (0x0E) is re, long u (0x2A) is oo.

Consonants (both tables)

LetterCodeLetterCodeLetterCodeLetterCode
b0x07g0x12m0x1At0x26
c0x23 / 0x18h0x00p0x21v0x2D
d0x09j0x17q0x18w0x2E
f0x11k0x18r0x22x0x31
l0x19s0x23y0x15
z0x30

Only one context rule exists: c is soft (0x23, same as s) when the next letter is i or e, hard (0x18, same as k) otherwise. h maps to 0x00, which has no instrument — h is silent unless it merges into a digraph.

Digits

0123456789
0x320x330x260x270x340x350x360x370x100x38

Eight of the ten reuse a code the letter tables also produce, and the overlaps line up with the first syllable of the spoken number: 0=ze(ro), 1=wo(one), 2=t(wo), 4=fo(ur), 5=fa(five), 6=si(x), 7=se(ven), 9=na(nine). Only 3 (0x27) and 8 (0x10) get codes nothing else reaches. The digraph names here are just labels for shared samples — the recording itself presumably says the number.

The deferred second syllable — mode 2

Sou_SpecialRoutine10 (:1708) runs once per game frame inside Na_GameFrame:

if (sou_Na_VoiceSeFlag == 1) {
    sou_Na_VoiceSeFlag = 0;               // a real call happened this frame
} else if (sou_num2_request) {
    Na_VoiceSe(sou_num2_request, 0, 0, sou_num2_animal_id, sou_num2_scale, 2);
    sou_num2_request = 0;
}

So the queued b plays on the first frame where no fresh call arrived. Mode 2 is a stripped-down path:

if (sou_voice_mode == 1) return;                    // click mode: no second syllable
if (a == 0xFF) return;
if (a == 'e' && sou_num3_org > 0x77 &&
    class(sou_last_num2) == vowel && class(sou_num_org) == consonant)
    a = 0;                                          // silent-e check again
else
    a = Sou_TanboinHenkan(a, sou_num3_org);         // always short

The queued syllable never gets the long-vowel treatment and never re-checks digraphs, which is why the second half of a pair always sounds flatter than the first.

Anti-repetition

// mode 0
if (sou_num_org == sou_last_num2 && sou_num_org == sou_last_num) i = 1;
…
if (spec == 5 || spec == 6 || r29 > 0x77) i = 0;
if (i == 1) a = Nap_GetRandom() >> 2;               // 0x00–0x3F

Three identical voice codes in a row — aaa, mmm, zzz — substitute a uniformly random phoneme from the first 64. This is the only path that reaches many of the codes never produced by any rule, and it is why repeated-letter interjections in dialogue sound like nonsense rather than a stutter. It is disabled for the player's voice (specs 5 and 6).

♪ — the melody escape

if (a == 0x80) {
    if (voice == 0xFA) return;
    switch (sou_voice_mode) {
        case 0: Na_MelodyVoice(voice); break;      // animalese
        case 1: Na_SysTrgStart(NA_SE_BEBE); break; // click
        case 2: break;                             // silent
    }
    return;
}

Na_MelodyVoice (src/static/jaudio_NES/game/melody.c:727) plays a fixed 16-note pattern {13, 14 × 15} on subtrack 6 at volume 1.3, using the speaker's npc_voice_id to select which chunk of sequence 248 to copy in. The id is the NPC's instrument type (draw_data.voice_type), the same one used for the town tune — so ♪ plays "their" instrument rather than a phoneme.

← 3. Windowing & timing 5. Playback →