Repository navigation
Expand file tree
/
Copy pathcbox.zig
More file actions
628 lines (571 loc) · 28.2 KB
/
Copy pathcbox.zig
File metadata and controls
628 lines (571 loc) · 28.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
//! `C:\lancer\game\cbox.cpp`: the radio's speech, the lines the pilots say, which come from
//! `ms_speech\msspeech.hog` as files of the game's own codec ([`voice.zig`](voice.zig)),
//! scrambled ([docs/formats/speech.md](../../../docs/formats/speech.md)). The game streams a line
//! through one sample of Miles's, decoding 2048 frames at a time as a timer asks for them
//! (`speech_timer`, `0x00462190`); OpenReliant decodes a line whole as it starts and plays it as
//! a sample (`Player`). **Unverified:** most of it lies before this file's known code
//! (`0x00462110` to `0x00462190`), from `0x00461EB0` on, after `camera.cpp`'s.
const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const log = std.log.scoped(.radio);
const layout = @import("../../formats/layout.zig");
const scramble = @import("../../formats/scramble.zig");
const wave = @import("../../formats/wave.zig");
const math = @import("../surrender/math.zig");
const mss = @import("../mss.zig");
const loudness = mss.loudness;
const hog_snd = @import("hog_snd.zig");
const voice = @import("voice.zig");
const ticks_per_second = @import("main.zig").ticks_per_second;
/// The rate the speech plays at (`0x00461FA1`), which the game plays as stereo, each channel the
/// same.
pub const rate = 22050;
/// The extension of a line's name as the game's code gives it, such as `plck_001.ut`. The speech
/// archive's members leave it out (`bigfile.memberName`).
pub const extension = ".ut";
/// The key the files are scrambled with (`speech_unscramble`, `0x00462000`), repeating from the
/// stream's start.
pub const key = [4]u8{ 0xAB, 0x2D, 0x9A, 0xAA };
/// A speech file's header.
pub const Header = extern struct {
/// The file's length past this field.
length: u32,
/// Its tag: `line_magic` or `scene_magic`.
magic: [4]u8,
/// The speech's size as 16-bit samples, in bytes: twice its samples.
size: u32,
/// The tags the game's speech files carry: the radio's lines, and the scenes, the `.box` files
/// of the discs' archives, which the Reliant's rooms and the briefings play. The game plays a
/// file whatever its tag.
pub const line_magic = "CB00";
pub const scene_magic = "CB97";
/// Whether it carries one of the tags the game's files carry.
pub fn tagged(header: Header) bool {
return std.mem.eql(u8, &header.magic, line_magic) or std.mem.eql(u8, &header.magic, scene_magic);
}
/// The header of `bytes`, a speech file, scrambled or not. Null for a file too short for it or
/// not tagged as the game's are (`tagged`).
pub fn of(bytes: []const u8) ?Header {
const header = layout.view(Header, bytes) catch return null;
return if (header.tagged()) header.* else null;
}
/// How many samples the speech plays: half its size (`0x00461F20`).
pub fn samples(header: Header) usize {
return header.size >> 1;
}
/// How many of the game's ticks it plays for at `rate`, rounded up.
pub fn ticks(header: Header) u32 {
return ticksOf(header.samples(), rate);
}
comptime {
assert(@sizeOf(Header) == 12);
}
};
/// How many bytes at a stream's end the game leaves scrambled (`length - 0x18` unscrambled from
/// the header's end), and how many the files leave plain: they are scrambled to 8 bytes before
/// their end, which hold the stream's last bits.
pub const game_plain_tail = 16;
pub const plain_tail = 8;
/// A speech file: its header, and its stream unscrambled.
pub const Speech = struct {
header: Header,
stream: []const u8,
/// `bytes`, a speech file as the archive holds it, unscrambled in place (`unscramble`). Null
/// for a file too short for its header or not tagged as the game's are (`Header.tagged`), which
/// the game does not check, so that what else an archive holds is passed over.
pub fn parse(bytes: []u8) ?Speech {
const header = Header.of(bytes) orelse return null;
unscramble(bytes);
return .{ .header = header, .stream = bytes[@sizeOf(Header)..] };
}
};
/// How many of the game's ticks `samples` of each channel play for at `at_rate`, rounded up; none
/// at no rate.
fn ticksOf(samples: u64, at_rate: u32) u32 {
if (at_rate == 0) return 0;
return @intCast(@min(@divCeil(samples * ticks_per_second, at_rate), std.math.maxInt(u32)));
}
/// A line the speech player plays: one in the game's codec (`Speech`), or a mod's recording, a
/// WAVE file that `wave.Decoder` reads, mono or stereo, at its own rate. A recording in another
/// format is decoded to a WAVE file as it's read (`recordings.Source.read`).
///
/// **Improvement:** the original plays its own codec alone.
pub const Line = union(enum) {
coded: Speech,
recorded: wave.Wave,
/// `bytes` as a WAVE file if they start like one, or else as a speech file (`Speech.parse`),
/// which unscrambles it in place, so a file is parsed once; null for neither, or for a WAVE
/// file the decoder can't read.
pub fn parse(bytes: []u8) ?Line {
if (isRecording(bytes)) return .{ .recorded = readRecording(bytes) orelse return null };
return .{ .coded = Speech.parse(bytes) orelse return null };
}
/// How many of the game's ticks the line in `bytes` plays for (`ticks`), read without
/// unscrambling `bytes`, so that whatever only times a line leaves the file for the player to
/// parse; null where `parse` finds no line.
pub fn ticksIn(bytes: []const u8) ?u32 {
if (isRecording(bytes)) {
const line: Line = .{ .recorded = readRecording(bytes) orelse return null };
return line.ticks();
}
const header = Header.of(bytes) orelse return null;
return header.ticks();
}
fn isRecording(bytes: []const u8) bool {
return std.mem.startsWith(u8, bytes, "RIFF");
}
/// `bytes` as a WAVE file the decoder can read.
fn readRecording(bytes: []const u8) ?wave.Wave {
const recorded = wave.Wave.parse(bytes) catch return null;
_ = wave.Decoder.init(recorded) catch return null;
return recorded;
}
/// Samples a second.
pub fn rate(line: Line) u32 {
return switch (line) {
.coded => speech_rate,
.recorded => |recorded| recorded.rate,
};
}
/// How many of the game's ticks it plays for, rounded up.
pub fn ticks(line: Line) u32 {
return switch (line) {
.coded => |speech| speech.header.ticks(),
.recorded => |recorded| ticksOf(recorded.frameCount(), recorded.rate),
};
}
};
/// The rate of the game's codec (`rate`), as `Line.rate` names it.
const speech_rate = rate;
/// `speech_unscramble` (`0x00462000`): the stream of `bytes`, a speech file, XORed with `key`
/// (`scramble.xor`), unless the file's first bytes are `man`, which the game writes over a file's
/// length as it unscrambles it, or `CB`. OpenReliant leaves the length.
///
/// **Fix:** the game unscrambles all but the stream's last `game_plain_tail` bytes, where the
/// files are scrambled to their last `plain_tail`, so it reads a line's last 8 bytes scrambled,
/// which end the last frame in noise. OpenReliant unscrambles them.
pub fn unscramble(bytes: []u8) void {
if (bytes.len < @sizeOf(Header)) return;
if (std.mem.startsWith(u8, bytes, "man") or std.mem.startsWith(u8, bytes, "CB")) return;
const stream = bytes[@sizeOf(Header)..];
if (stream.len <= plain_tail) return;
scramble.xor(stream[0 .. stream.len - plain_tail], key);
}
/// How the radio's lines sound, OpenReliant's own choices; `original` is the game's.
pub const Style = struct {
peaks: Peaks = .rounded,
room: Room = .cabin,
levels: Levels = .matched,
/// What becomes of the loudest peaks, which the recordings push past full scale in a few
/// samples of every thousand.
pub const Peaks = enum {
/// **Improvement:** rounded off (`softClip`), so that they keep their shape.
rounded,
/// Cut flat at full scale, as the game cuts them, which crackles.
cut,
};
/// Where the lines are heard.
pub const Room = enum {
/// **Improvement:** in the cockpit's cabin, as Betty's warnings are (`mss.Room.cockpit`).
cabin,
/// **Improvement:** in what surrounds the listener, as the scene's sounds are
/// (`mss.Room.scene`): Enriquez's in person, in the briefing room.
scene,
/// Dry, as the game plays them.
dry,
};
/// How loud a line plays beside the recording it follows (`Player.start`).
pub const Levels = enum {
/// **Improvement:** brought down to that recording's loudness where it is louder
/// (`bringDown`), as Enriquez's last word is to the briefing's narration, whose
/// recordings are mastered quieter.
matched,
/// As the recordings have them.
recorded,
};
pub const original: Style = .{ .peaks = .cut, .room = .dry, .levels = .recorded };
};
/// **Improvement:** `samples`, interleaved in `channels` at `at_rate`, brought down to the
/// loudness `target`, in LUFS, where they are louder, as ITU-R BS.1770 measures both
/// (`mss.loudness`).
pub fn bringDown(gpa: Allocator, samples: []i16, channels: u16, at_rate: u32, target: f32) Allocator.Error!void {
const measured = try loudness.integrated(gpa, samples, channels, at_rate) orelse return;
if (measured <= target) return;
const gain = mss.decibelGain(f32, target - measured);
for (samples) |*value| value.* = @intFromFloat(@round(@as(f32, @floatFromInt(value.*)) * gain));
}
/// Where the rounding of the peaks begins, as a share of full scale (`Style.Peaks.rounded`).
pub const knee: f32 = 0.8;
/// `value`, a sample of the codec's, with its peaks rounded off: unchanged within `knee` of full
/// scale, and past it eased toward full scale by a hyperbolic tangent, which starts at the same
/// slope, so that a peak of any height comes to full scale at most.
pub fn softClip(value: f32) f32 {
const full: f32 = std.math.maxInt(i16);
const bend = knee * full;
const over = @abs(value) - bend;
if (over <= 0) return value;
const room = full - bend;
return std.math.sign(value) * (bend + room * std.math.tanh(over / room));
}
/// A sample as `speech_fetch` (`0x00462290`) and `speech_timer` make it of the codec's float: the
/// nearest whole number, halves to even, which the game gets by adding 12582912 (`0x004DC798`)
/// and reading the float's low bits, held to 16 bits; its peaks first rounded off where `peaks`
/// says (`softClip`).
///
/// **Fix:** the game reads 17 bits, so a peak past 65536 either way comes round wrong; OpenReliant
/// holds it.
pub fn sample(value: f32, peaks: Style.Peaks) i16 {
const held = switch (peaks) {
.rounded => softClip(value),
.cut => value,
};
return @intCast(std.math.clamp(math.round(held), std.math.minInt(i16), std.math.maxInt(i16)));
}
/// The line decoded whole, in `gpa`: `Speech.samples` of it, a frame at a time
/// (`voice.Decoder.frame`), as `speech_fetch` decodes it as the sample plays, its peaks as
/// `peaks` has them.
pub fn decode(gpa: Allocator, speech: Speech, peaks: Style.Peaks) Allocator.Error![]i16 {
const out = try gpa.alloc(i16, speech.header.samples());
var decoder: voice.Decoder = .init(speech.stream);
var at: usize = voice.frame_samples;
for (out) |*value| {
if (at == voice.frame_samples) {
decoder.frame();
at = 0;
}
value.* = sample(decoder.state.samples()[at], peaks);
at += 1;
}
return out;
}
/// `line` decoded into a WAVE file of 16-bit PCM made in `gpa`. Where `style` matches the levels
/// and a recording came before it, `follows` LUFS loud, the line is brought down to that loudness.
/// A recording keeps its rate and channels, and its peaks, which are within full scale already.
fn prepared(gpa: Allocator, line: Line, style: Style, follows: ?f32) (Allocator.Error || wave.Decoder.Error)![]u8 {
const samples, const channels = switch (line) {
.coded => |speech| .{ try decode(gpa, speech, style.peaks), 1 },
.recorded => |recorded| .{ try decodeRecording(gpa, recorded), recorded.channels },
};
defer gpa.free(samples);
if (style.levels == .matched) if (follows) |target| try bringDown(gpa, samples, channels, line.rate(), target);
return wave.pcm16(gpa, line.rate(), channels, samples);
}
/// The samples of `recorded`, a WAVE file `wave.Decoder` reads, as 16-bit PCM made in `gpa`,
/// interleaved in its channels.
fn decodeRecording(gpa: Allocator, recorded: wave.Wave) (Allocator.Error || wave.Decoder.Error)![]i16 {
var decoder: wave.Decoder = try .init(recorded);
return decoder.rest(gpa);
}
/// The speech as it plays through the speech sample (`speech_start`, `0x00461EB0`; `speech_stop`,
/// `0x00462070`; `speech_playing`, `0x004620A0`): the game keeps eight streams, of which its timer
/// plays the first playing; OpenReliant plays one line at a time, decoded whole into a WAVE file
/// the sample plays.
pub const Player = struct {
/// The line playing, as the sample's file, kept while it plays.
file: []u8 = &.{},
/// **Improvement:** where there's no speech sample to play a line on (`hog_snd.Sound.speech`),
/// as with `--no-sound`, the line still lasts as long as it would play, timed by the game's
/// clock (`hog_snd.Sound.clock`). Whatever waits for the line then waits as long as with
/// sound: the radio's window shows the speaker's face, and a scene in the rooms runs its
/// length. Otherwise the line would end as soon as it started.
unheard: Unheard = .none,
/// A line that nobody hears.
const Unheard = union(enum) {
none,
/// It plays until the game's clock reaches this tick (`Clock.game_ticks`).
until: u32,
/// It is paused, with this many ticks left.
held: u32,
};
/// `speech_start`: `line` played through `sound`'s speech sample, the line playing ended:
/// once, in the middle, at its rate, as loud as the speech volume, the master volume and
/// `volume`, 0 to 127, make it (`hog_snd.Sound.speechVolume`), sounding as `style` has it; where
/// it follows a recording `follows` LUFS loud, matched to it as the style's levels have it.
/// Where there's no speech sample, timed by the game's clock instead (`unheard`). Whether it
/// plays.
///
/// Not ported: a line that loops (bit 0 of the game's flags), which nothing the game ships
/// asks for.
pub fn start(player: *Player, gpa: Allocator, sound: *hog_snd.Sound, line: Line, volume: i32, style: Style, follows: ?f32) bool {
player.stop(gpa, sound);
const driver = sound.driver orelse return player.time(sound, line);
const handle = sound.speech orelse return player.time(sound, line);
player.file = prepared(gpa, line, style, follows) catch |err| {
log.warn("a line of speech cannot be decoded: {t}", .{err});
return false;
};
driver.initSample(handle);
if (!driver.setSampleFile(handle, player.file)) {
log.warn("a line of speech cannot be played", .{});
return false;
}
driver.setSampleRoom(handle, switch (style.room) {
.cabin => .cockpit,
.scene => .scene,
.dry => .none,
});
driver.setSampleLoopCount(handle, hog_snd.once);
driver.setSamplePan(handle, hog_snd.centre);
driver.setSamplePlaybackRate(handle, line.rate());
driver.setSampleVolume(handle, sound.speechVolume(volume));
driver.startSample(handle);
return true;
}
/// A line nobody hears, `line`, timed from now by `sound`'s clock (`unheard`). Whether it
/// plays, which it does where the sound has the clock.
fn time(player: *Player, sound: *const hog_snd.Sound, line: Line) bool {
const clock = sound.clock orelse return false;
player.unheard = .{ .until = clock.game_ticks +% line.ticks() };
return true;
}
/// `speech_stop`: the line playing ended, and its file let go of.
pub fn stop(player: *Player, gpa: Allocator, sound: *hog_snd.Sound) void {
if (sound.driver) |driver| if (sound.speech) |handle| {
if (driver.sampleStatus(handle) != .done) driver.endSample(handle);
};
player.deinit(gpa);
}
/// The line playing stopped where it is (`AIL_stop_sample` on the speech sample), or where it
/// was stopped so, going on (`AIL_resume_sample`), as the in-game options open and close over
/// the loadout. A line nobody hears keeps the ticks it had left while it is paused.
pub fn pause(player: *Player, sound: *hog_snd.Sound, paused: bool) void {
if (sound.clock) |clock| switch (player.unheard) {
.none => {},
.until => |until| if (paused) {
player.unheard = .{ .held = ticksLeft(clock.game_ticks, until) };
},
.held => |left| if (!paused) {
player.unheard = .{ .until = clock.game_ticks +% left };
},
};
if (player.file.len == 0) return;
const driver = sound.driver orelse return;
const handle = sound.speech orelse return;
if (paused) {
if (driver.sampleStatus(handle) == .playing) driver.stopSample(handle);
} else if (driver.sampleStatus(handle) == .stopped) driver.resumeSample(handle);
}
/// `speech_playing`: whether a line plays, or for a line nobody hears, whether it is paused or
/// its time has yet to run out.
pub fn playing(player: Player, sound: *hog_snd.Sound) bool {
const driver = sound.driver orelse return player.unheardPlays(sound);
const handle = sound.speech orelse return player.unheardPlays(sound);
return driver.sampleStatus(handle) == .playing;
}
/// Whether the line nobody hears still plays: paused, or not yet at its end on the clock.
fn unheardPlays(player: Player, sound: *const hog_snd.Sound) bool {
return switch (player.unheard) {
.none => false,
.held => true,
.until => |until| if (sound.clock) |clock| ticksLeft(clock.game_ticks, until) > 0 else false,
};
}
/// The line let go of, heard or not.
pub fn deinit(player: *Player, gpa: Allocator) void {
gpa.free(player.file);
player.file = &.{};
player.unheard = .none;
}
};
/// The game's ticks from `now` until `until`, none once it has passed. The clock's count wraps,
/// so the difference is read as signed.
fn ticksLeft(now: u32, until: u32) u32 {
const left: i32 = @bitCast(until -% now);
return @intCast(@max(left, 0));
}
/// A speech file for the tests: a header for `samples` samples over a stream of `stream_len`
/// zero bits, scrambled as the files are, in `gpa`.
pub fn testFile(gpa: Allocator, samples: u32, stream_len: usize) Allocator.Error![]u8 {
const file = try gpa.alloc(u8, @sizeOf(Header) + stream_len);
const header: Header = .{ .length = @intCast(file.len - 4), .magic = Header.line_magic.*, .size = samples * 2 };
@memcpy(file[0..@sizeOf(Header)], std.mem.asBytes(&header));
@memset(file[@sizeOf(Header)..], 0);
unscramble(file);
return file;
}
test unscramble {
const gpa = std.testing.allocator;
const file = try testFile(gpa, 100, 24);
defer gpa.free(file);
// Scrambled to 8 bytes before the end, the stream shows the key over its zeros, and plain
// zeros after; unscrambled, all zeros.
try std.testing.expectEqualSlices(u8, &(key ++ key), file[@sizeOf(Header)..][0..8]);
try std.testing.expectEqualSlices(u8, &(key ++ key), file[@sizeOf(Header)..][8..16]);
try std.testing.expectEqualSlices(u8, &@as([8]u8, @splat(0)), file[@sizeOf(Header)..][16..24]);
unscramble(file);
try std.testing.expectEqualSlices(u8, &@as([24]u8, @splat(0)), file[@sizeOf(Header)..]);
// Marked unscrambled, a file is left alone.
var marked = "man\x00CB00\x00\x00\x00\x00abcdefghijklmnop".*;
unscramble(&marked);
try std.testing.expectEqualStrings("abcdefghijklmnop", marked[12..]);
}
test Speech {
const gpa = std.testing.allocator;
const file = try testFile(gpa, 1000, 40);
defer gpa.free(file);
const speech = Speech.parse(file).?;
try std.testing.expectEqual(1000, speech.header.samples());
try std.testing.expectEqual(40, speech.stream.len);
try std.testing.expectEqual(0, speech.stream[0]);
// 1000 samples play for a little over 4.5 ticks, rounded up to 5; a second's samples for 100.
try std.testing.expectEqual(5, speech.header.ticks());
const second = try testFile(gpa, rate, 40);
defer gpa.free(second);
try std.testing.expectEqual(100, Speech.parse(second).?.header.ticks());
// A scene is read alike.
const scene = try testFile(gpa, 10, 16);
defer gpa.free(scene);
scene[4..8].* = Header.scene_magic.*;
try std.testing.expectEqual(10, Speech.parse(scene).?.header.samples());
// Not a speech file.
var other = "RIFF\x00\x00\x00\x00WAVEfmt ".*;
try std.testing.expectEqual(null, Speech.parse(&other));
var short = "CB".*;
try std.testing.expectEqual(null, Speech.parse(&short));
}
test sample {
try std.testing.expectEqual(0, sample(0.5, .cut));
try std.testing.expectEqual(2, sample(1.5, .cut));
try std.testing.expectEqual(-2, sample(-2.4, .cut));
try std.testing.expectEqual(32767, sample(40000, .cut));
try std.testing.expectEqual(-32768, sample(-70000, .cut));
// Rounded off, a peak stays short of full scale.
try std.testing.expectEqual(2, sample(1.5, .rounded));
try std.testing.expect(sample(40000, .rounded) < 32767);
}
test softClip {
// Within the knee, unchanged; past it, eased toward full scale, in order and either way alike.
try std.testing.expectEqual(1000, softClip(1000));
try std.testing.expectEqual(-26000, softClip(-26000));
const bend = knee * 32767;
try std.testing.expect(softClip(30000) > bend and softClip(30000) < 30000);
try std.testing.expect(softClip(40000) > softClip(30000));
try std.testing.expect(softClip(400000) <= 32767 and softClip(400000) > 32700);
try std.testing.expectEqual(-softClip(40000), softClip(-40000));
}
test decode {
const gpa = std.testing.allocator;
// Zero bits: no pulses, so silence, as many samples as the header says.
const file = try testFile(gpa, 1000, 400);
defer gpa.free(file);
const samples = try decode(gpa, Speech.parse(file).?, .rounded);
defer gpa.free(samples);
try std.testing.expectEqual(1000, samples.len);
for (samples) |value| try std.testing.expectEqual(0, value);
}
test bringDown {
const gpa = std.testing.allocator;
// A loud tone brought down to the loudness it follows, 12 dB quieter; then left as it is
// beside a louder one.
var samples: [rate]i16 = undefined;
for (&samples, 0..) |*value, n| {
const t = @as(f32, @floatFromInt(n)) / rate;
value.* = @intFromFloat(@round(16000 * @sin(2 * std.math.pi * 997 * t)));
}
const before = (try loudness.integrated(gpa, &samples, 1, rate)).?;
try bringDown(gpa, &samples, 1, rate, before - 12);
try std.testing.expectApproxEqAbs(before - 12, (try loudness.integrated(gpa, &samples, 1, rate)).?, 0.1);
const quieter = samples;
try bringDown(gpa, &samples, 1, rate, before);
try std.testing.expectEqualSlices(i16, &quieter, &samples);
}
test Player {
const gpa = std.testing.allocator;
var speaker: hog_snd.testing.Speaker = undefined;
try speaker.init(2, null);
const sound = &speaker.sound;
defer sound.shutdown();
var player: Player = .{};
defer player.deinit(gpa);
const file = try testFile(gpa, 2000, 400);
defer gpa.free(file);
const speech = Speech.parse(file).?;
try std.testing.expect(!player.playing(sound));
try std.testing.expect(player.start(gpa, sound, .{ .coded = speech }, hog_snd.loudest, .{}, null));
try std.testing.expect(player.playing(sound));
try std.testing.expect(player.file.len > 0);
player.stop(gpa, sound);
try std.testing.expect(!player.playing(sound));
try std.testing.expectEqual(0, player.file.len);
// Without the memory to decode it, the line doesn't play.
var failing: std.testing.FailingAllocator = .init(gpa, .{ .fail_index = 0 });
try std.testing.expect(!player.start(failing.allocator(), sound, .{ .coded = speech }, hog_snd.loudest, .{}, null));
try std.testing.expect(!player.playing(sound));
try std.testing.expectEqual(0, player.file.len);
// A mod's recording plays at its own rate and in its own channels.
const samples = [_]i16{ 100, -100, 2000, -2000, 0, 0 };
const recording = try wave.pcm16(gpa, 11025, 2, &samples);
defer gpa.free(recording);
try std.testing.expect(player.start(gpa, sound, Line.parse(recording).?, hog_snd.loudest, .{}, null));
try std.testing.expect(player.playing(sound));
const played = try wave.Wave.parse(player.file);
try std.testing.expectEqual(11025, played.rate);
try std.testing.expectEqual(2, played.channels);
try std.testing.expectEqualSlices(u8, std.mem.sliceAsBytes(&samples), played.data);
player.stop(gpa, sound);
}
test Line {
const gpa = std.testing.allocator;
// The game's speech file, at the codec's rate.
const file = try testFile(gpa, 2000, 400);
defer gpa.free(file);
// Timing it reads its header alone, and leaves the file as the archive holds it, for the
// player to unscramble as it parses it.
const archived = try gpa.dupe(u8, file);
defer gpa.free(archived);
const ticks = Line.ticksIn(file).?;
try std.testing.expectEqualSlices(u8, archived, file);
const coded = Line.parse(file).?;
try std.testing.expectEqual(rate, coded.rate());
try std.testing.expectEqual(coded.coded.header.ticks(), coded.ticks());
try std.testing.expectEqual(ticks, coded.ticks());
// A recording of a second at 8000 samples a second, mono, plays for a second of ticks.
const quiet: [8000]i16 = @splat(0);
const recording = try wave.pcm16(gpa, 8000, 1, &quiet);
defer gpa.free(recording);
const recorded = Line.parse(recording).?;
try std.testing.expectEqual(8000, recorded.rate());
try std.testing.expectEqual(ticks_per_second, recorded.ticks());
try std.testing.expectEqual(ticks_per_second, Line.ticksIn(recording).?);
// One whose header gives no rate plays for no ticks.
var rateless = recorded;
rateless.recorded.rate = 0;
try std.testing.expectEqual(0, rateless.ticks());
// A WAVE file the decoder can't read, such as one of 24-bit samples, is neither.
const wide = try gpa.dupe(u8, comptime wave.testing.file(.{ .format = .pcm, .channels = 1, .rate = 8000, .byte_rate = 24000, .block_align = 3, .bits = 24 }, "", "", "\x00\x00\x00"));
defer gpa.free(wide);
try std.testing.expectEqual(null, Line.parse(wide));
try std.testing.expectEqual(null, Line.ticksIn(wide));
}
test "a line nobody hears is timed by the game's clock" {
const gpa = std.testing.allocator;
// Without a driver, as with `--no-sound`, there's no speech sample. The clock is near its wrap.
var clock: @import("main.zig").Clock = .{ .game_ticks = std.math.maxInt(u32) - 5 };
var sound: hog_snd.Sound = .{ .clock = &clock };
var player: Player = .{};
defer player.deinit(gpa);
// A second of speech: 100 ticks.
const file = try testFile(gpa, rate, 40);
defer gpa.free(file);
const speech = Speech.parse(file).?;
try std.testing.expect(player.start(gpa, &sound, .{ .coded = speech }, hog_snd.loudest, .{}, null));
clock.game_ticks +%= 99;
try std.testing.expect(player.playing(&sound));
// Paused, it keeps the tick it had left, however long the pause lasts.
player.pause(&sound, true);
clock.game_ticks +%= 1000;
try std.testing.expect(player.playing(&sound));
player.pause(&sound, false);
try std.testing.expect(player.playing(&sound));
clock.game_ticks +%= 1;
try std.testing.expect(!player.playing(&sound));
// Stopped, it's over at once.
try std.testing.expect(player.start(gpa, &sound, .{ .coded = speech }, hog_snd.loudest, .{}, null));
player.stop(gpa, &sound);
try std.testing.expect(!player.playing(&sound));
// Without the clock, a line ends as it starts.
sound.clock = null;
try std.testing.expect(!player.start(gpa, &sound, .{ .coded = speech }, hog_snd.loudest, .{}, null));
try std.testing.expect(!player.playing(&sound));
}