游戏引擎学习第138天

仓库:https://gitee.com/mrxiao_com/2d_game_3

资产:game_hero_test_assets_003.zip 发布

我们的目标是展示游戏运行时的完整过程,从像素渲染到不使用GPU的方式,我们自己编写了渲染器并完成了所有的工作。今天我们开始了一些新的内容,觉得现在是一个合适的时机,整理一下之前的工作,开始着手声音部分的开发。声音是一个像图形一样的领域,许多人只会用现成的库,而不深入理解其内部原理。然而,我们的目标是教育,帮助大家了解其中的每个环节,理解这些东西的工作原理,声音处理就是其中之一。通过理解声音,你能做很多事情,而如果只使用库而不清楚其原理,可能就无法做到这些。

为了实现这一目标,game_test_asset\data\test3这个文件夹中包含了我自己制作的WAV文件,我确保这些文件没有版权问题,可以自由使用并复制。我们今天的目标是加载这些WAV文件,以便我们能够进行播放,从而完善我们的资源加载系统。目前,我们的资源加载系统只能处理位图,还不能处理WAV文件,因此需要扩展它来支持WAV文件的加载。

为了实现这一点,我们需要理解WAV文件的结构,因此我们需要实现WAV文件的加载代码。WAV文件虽然比位图稍微复杂一些,但它们还是相对容易处理的文件格式。作为第一步,我们需要能够加载WAV数据并进行处理,这样后续的音频混音等功能才能继续进行。

解析 WAV 文件中的数据块(Chunks)

WAV 文件采用 块(Chunk)结构 ,即文件内部是由多个独立的子块组成的,每个子块都有自己的 Chunk IDChunk Size

  • 我们遍历整个 WAV 文件,按 Chunk ID 解析各个部分,例如:
    • "fmt "(格式块):定义音频格式(PCM、采样率、通道数等)。
    • "WAVE"(数据块):存储真正的音频数据。
    • 其他扩展块(可忽略)。

遍历数据块

  1. 定义数据块结构

    c 复制代码
    typedef struct {
        uint32_t ID;   // 数据块类型("fmt "、"data" 等)
        uint32_t Size; // 数据块大小
    } WaveChunk;
  2. 遍历所有数据块

    c 复制代码
    uint8_t* cursor = fileData + sizeof(WaveHeader);
    uint8_t* fileEnd = fileData + header->Size + 8;
    
    while (cursor < fileEnd) {
        WaveChunk* chunk = (WaveChunk*)cursor;
    
        // 处理 "fmt " 数据块
        if (chunk->ID == RIFF_CODE('f', 'm', 't', ' ')) {
            // 解析格式信息
        }
        // 处理 "data" 数据块
        else if (chunk->ID == RIFF_CODE('d', 'a', 't', 'a')) {
            // 解析音频数据
        }
    
        // 移动到下一个数据块
        cursor += sizeof(WaveChunk) + chunk->Size;
    }

总结

  1. 检查 WAV 头部
    • ID 必须是 "RIFF"Format 必须是 "WAVE",否则 assert 失败。
  2. 遍历数据块
    • 读取 IDSize,根据不同 ID 解析不同数据块。
    • 关键数据块包括 "fmt "(格式信息)和 "data"(音频数据)。
  3. 使用 assert 进行调试
    • 该代码仅用于调试模式,最终的资产加载器将进行更完整的错误处理。

这样,我们就完成了 WAV 文件的基本解析框架,后续可以继续实现对 "fmt ""data" 数据块的具体解析。

game_asset.cpp:修正 WAVE_fmt 中的拼写错误

我们现在正在查看资源加载器,尤其是加载WAV文件的部分。昨天有观众指出,在我编写WAV文件头部时,可能有一个拼写错误,我记得可能是在格式块(format chunk)里出错了,不过我不记得具体是哪一项。经过检查,发现确实是"有效位数"这一项出错了,我把它写成了4,但应该是2,所以感谢观众指出这个问题,避免了额外的调试工作。

昨天的工作主要是开始构建WAV文件的基础结构。我们并没有做太多,只是初步地复制了WAV文件的结构。接下来,我打算把这些内容放到和位图定义相同的位置,以确保它们在同一个pragma pack指令内。我们希望这些结构体能够使用pragma pack 1,这样编译器就不会插入额外的空间。例如,int16类型的数据可能会被填充到32位,但是如果我们使用pragma pack指令,就能够确保数据按需对齐,不会出现额外的填充。

总之,我要确保这些数据在内存中是紧凑排列的,不会被编译器处理成其他格式,接下来会继续优化这些部分,保证WAV文件的加载能够顺利进行。

game.cpp:在 GameUpdateAndRender 中调用 DEBUGLoadWAV

为了测试这些功能,首先决定在代码中进行一些调整,以便更高效地进行测试,而无需每次都运行整个游戏。具体的做法是在每次更新和渲染的调用开始时,插入一段代码来加载一个 WAV 文件。这样一来,可以在代码的某个特定位置设置断点,这样测试就可以从该位置直接开始,避免了繁琐的启动游戏过程。这种方式能够提高测试效率,特别是当需要调试和测试一些功能时。

为了实现这一点,决定在代码中添加一个"加载声音"的步骤,示例文件为 bloop000.wav,这样就可以确保每次进入测试时,程序会加载指定的音频文件,方便检查和验证音频加载的相关功能。

调试器:进入 DEBUGLoadWAV

多写了一个data/test

编译并运行代码,确保文件能够完整加载。查看 WAV 文件的头部信息,检查文件的尺寸参数是否符合预期,并确认 RIFF ID 是否正确。检查时,发现 RIFF ID 确实按照小端字节序(little-endian)排列,并且文件头中的字符代码为 52 49 46 46,与预期一致。此外,文件中的 WAV ID 也符合预期的字符码 57 41 56 45。

通过这些检查,确认 WAV 文件头部的加载没有问题,断言检查也通过了。接下来需要处理 WAV 文件的解析部分,继续进行文件解析的实现。

game_asset.cpp:引入 riff_iterator 用于 DEBUGLoadWAV

在实现 WAV 文件解析时,首先需要处理文件中的数据块(chunk)。为了方便处理这些块,设计了一个类似迭代器的工具来帮助管理读取文件的过程。这个工具将包含一个指向当前文件位置的指针以及当前处理的 RIFF 块的信息。

在实现中,首先解析文件头,然后使用迭代器开始读取文件中的第一个数据块。每个块有一个大小参数,迭代器将帮助逐步遍历每个块,并在每次读取后移动到下一个块。为了使这个过程更加清晰和易于管理,使用了一个循环结构来处理每个块。

在循环中,首先判断当前块是否有效,然后使用 NextChunk 函数来跳转到下一个块。每当读取一个块时,检查该块的类型,根据类型执行不同的操作。比如,如果读取的是格式块(format chunk),则根据块的内容进行相应的处理。

总的来说,这个实现的目的是为了在读取 WAV 文件时,能够高效地管理每个数据块,并根据不同块的类型执行不同的操作,简化了文件解析的过程。

game_asset.cpp:向 RIFF_CODE 添加 WAVE_ChunkID_data

在分析 WAV 文件格式时,发现其中有几个数据块(chunk),但并不是所有的数据块都需要关心。对于当前的需求,压缩格式的文件并不需要处理,因此可以忽略 fact 块。主要关注两个数据块:格式块(format chunk)和数据块(data chunk)。

格式块包含了 WAV 文件的基本信息,例如音频的格式和参数;数据块则包含了实际的音频样本数据。因此,在实现解析时,主要需要处理这两个块:格式块用于解析音频的格式,数据块则用于读取实际的声音数据。

在代码中,首先要读取格式块,这将帮助理解后续数据块的内容。然后,读取数据块,获取实际的音频数据。除了这两个块,其他的块可以暂时忽略,因为它们对当前目标没有实际意义。

简而言之,整个解析过程主要集中在格式块和数据块的读取,确保能够正确解析音频的格式并获取样本数据。
https://www.mmsp.ece.mcgill.ca/Documents/AudioFormats/WAVE/WAVE.html

game_asset.cpp:继续编写 DEBUGLoadWAV 的使用代码

接下来要做的事情是从 WAV 文件中获取实际的音频数据。为了实现这一点,可以使用 GetChunkData 函数来提取格式块(format chunk)和数据块(data chunk)。对于格式块(WAVE_fmt),可以通过迭代器获取该块的数据并存储为 WAVE_fmt 结构体。对于数据块(data chunk),虽然它本身只包含音频的字节数据,不包含其他信息,但依然可以使用 GetChunkData 来提取该数据,并将其保存为 SampleData

然后,提取到的样本数据会被保存,这样就能够获得实际的音频数据,准备进行后续处理。整体流程是:首先提取格式块数据来了解音频的基本信息,然后提取数据块以获取实际的音频样本数据。这样做可以帮助完成 WAV 文件的解析,进而能够进行后续的音频处理和播放。

game_asset.cpp:引入 ParseChunkAt

首先需要创建一个 riff_iterator,它将帮助在 WAV 文件的各个块之间进行迭代。这个迭代器将假定当前指向一个有效的块,例如 WAVE_chunk,这是迭代器的初始状态。迭代器的主要职责就是在文件中遍历这些块,提取所需的数据。接下来,还需要确保迭代器知道文件的大小,这样它才能知道何时超出文件的有效数据范围,避免访问无效区域。为了做到这一点,迭代器还需要存储当前块的大小,并且在读取数据时要对文件的边界进行检查。

此外,还需要实现一个 next 函数,该函数将帮助迭代器跳转到下一个有效的块,以便进行后续的读取和处理。在此过程中,要特别注意文件的边界和数据的有效性,以确保迭代过程的正确性。

game_asset.cpp:引入 NextChunk

首先,创建一个 riff_iterator NextChunk(riff_iterator Iter) 函数,用来推进到下一个块。在这个函数中,需要通过当前块的大小来决定要前进多少位置。根据 WAV 文件格式,块的大小通常是指块头以外的数据大小。因此,在计算前进的距离时,可能需要排除块头的部分。假设一个块的大小为 16 字节,如果块头的大小为 8 字节,那么有效数据的大小为 8 字节,前进时就需要跳过这部分数据。

为了处理这一情况,假设块大小不包括块头,代码会在当前指针位置上增加相应的有效数据长度。接着,为了确保正确解析,设置一个停止条件参数来控制迭代器在合适的位置停止。通过这种方法,可以有效地解析文件中的每个块,并确保在迭代时不会超出文件数据的有效范围。

game_asset.cpp:引入 IsValid

在实现 IsValid 方法时,核心目标是判断迭代器是否有效。具体来说,通过检查当前的迭代器位置(Iter.At)是否小于预设的停止位置(Iter.Stop)来确定迭代器是否仍然有效。如果当前迭代器位置小于停止点,则返回有效,表示可以继续迭代。如果不满足条件,则表示迭代器无效,无法继续迭代。

此外,遇到代码缩进问题时,需要确保所有的代码块按照正确的缩进规则进行排列,以避免编译器产生不必要的警告或错误。

game_asset.cpp:继续编写 DEBUGLoadWAV

在实现 ParseChunkAt 时,需要考虑如何处理数据的读取位置。具体而言,在解析时,会根据头部信息确定从哪里开始读取数据,并按照头部指定的大小读取后续数据。然而,是否应包括头部的大小,仍不确定。这意味着要判断头部的大小是否包含在内,可能需要通过调试或其他方法确认,具体的处理方式会在后续的测试中明确。

此外,尽管最初考虑为 GetChunkData 增加类型安全检查,但发现实际使用中并不需要,因为当前的实现并不会在多个不同场景下调用该方法。所以,最终决定不做额外的类型安全处理。

game_asset.cpp:引入 GetChunkData

在实现 GetChunkData 时,目标是返回块的数据内容。该方法通过从当前迭代器的位置返回数据,具体来说就是在头部信息之后的实际数据部分。为了简化,这些方法实际上都是简单的工具函数,因此最好将它们内联。

GetChunkData 方法会返回当前迭代器位置的指针,并且跳过头部部分,返回实际数据。GetType 方法则返回一个 32 位的数值,表示当前块的类型。对于 wave chunk,它包含一个 ID 和大小,方法应返回这个 ID,以便我们能够识别出块的类型。

调试器:进入 DEBUGLoadWAV

在进行第一次实现时,首先我们读取了文件并开始解析。当我们创建了一个迭代器后,查看迭代器的内容时,发现其包含了一个块ID。为了确定块ID的具体值,可以通过打印输出这个ID来查看。第一次解析时,遇到的是格式块(format chunk),并且我们确认其符合预期。

在查看这个格式块时,发现它包含了音频文件的一些重要信息,比如采样率为48kHz,立体声(两个通道)等,这符合预期。格式标签(format tag)也表明文件是PCM格式,这是我们计划读取的无压缩音频格式。

其他字段,比如块对齐(block align)和每个样本的位数(bits per sample),也看起来正常,16位的样本深度符合预期。不过,其中有一些字段(如CV size)可能对我们当前的读取操作没有太大影响。

接着,解析到下一个块时,发现又出现了一个格式块,这显然不符合预期。此时意识到,可能在代码中出现了某些错误,导致格式块被错误地重复读取。

game_asset.cpp:移除 riff_iterator 和 ParseChunkAt 中的 Chunk,改为在 NextChunk 和 GetType 中使用

在发现出现重复的块后,决定修复这个问题。问题出在一个冗余的指针上,且没有在移动时更新这个指针。因此,考虑到这个指针已经没有太大作用,决定将其移除,改为直接通过at(块头)来进行操作。这样可以避免之前的错误,并使代码更加简洁,避免出现不必要的重复读取。

通过这种方式,代码能够更加清晰和高效,同时也解决了之前的问题。

调试器:继续逐步调试 DEBUGLoadWAV

在查看下一个块类型时,发现它是数据块(data chunk),这是存储音频样本的地方。除了这个数据块,还有其他块,但它们不是我们能够理解的类型,因此跳过了这些块。为了进一步了解情况,决定查看一下这些未读取的块的类型。结果发现,数据块的类型是零,这让人感到有些奇怪。此时怀疑可能是在数据块的末尾四个字节附近,可能存在一些问题。

继续分析后,猜测可能存在一些数据对齐的问题或其他原因,导致读取到的数据块位置出现了偏差。

网络:尝试确定 RIFF 文件是否应以零结尾

在检查文件结尾时,发现没有明确的规范说明文件应该如何结束。因此,想确保没有出现错误。如果文件结尾确实应该是零字节,那就没有问题,但还是想确保没有 bug。查阅相关文档时,发现文档中提到文件的字节排列方式。特别是,当文件大小是奇数时,会在数据块后面添加一个零值的填充字节(pad byte)。这意味着如果块大小是奇数,文件会在数据部分后附加一个零字节,以确保文件对齐。

目前实现没有遵循这个规定,这可能导致文件末尾出现不符合规范的情况。因此,需要更新代码以遵循这一规则,确保文件按照规范处理。

然而,文档中没有完全解释为什么会出现读取到零值的现象,因此可能需要进一步调查。
https://www.mmsp.ece.mcgill.ca/Documents/AudioFormats/WAVE/Docs/riffmci.pdf

game_asset.cpp:如果 Chunk->Size 为奇数,则填充

在处理块大小时,如果块大小是奇数,那么需要根据规范进行填充。可以通过调整块大小,确保其为偶数。例如,可以通过将块大小加一来进行向上舍入,这样就符合规范,确保正确对齐。然后,按照新的大小进行处理。

尽管如此,这并没有完全解决问题,因为仍然不清楚为何会遇到零字节的情况。虽然文档中提到当块大小为奇数时需要进行填充,但这并没有完全解释为什么在特定情况下会出现零字节。因此,还需要进一步分析和确认这一现象的原因。

这行代码的作用是确保 Size 是一个偶数,即使 Chunk->Size 是奇数。

详细解释:

cpp 复制代码
uint32 Size = (Chunk->Size + 1) & ~1;
  1. Chunk->Size + 1:

    • 这部分代码将 Chunk->Size 增加 1。如果 Chunk->Size 是奇数,增加 1 后它会变成偶数;如果已经是偶数,则增加后变成下一个奇数。
  2. & ~1:

    • ~1 是按位取反操作,1 在二进制中是 00000001,因此 ~1 就是 11111110
    • 按位与(&)操作会将 Chunk->Size + 1 的最低有效位(最右边的那个二进制位)清零。这样,任何数的最低有效位都被强制设置为 0,从而使得最终的 Size 成为偶数。

例子:

  • 如果 Chunk->Size 是 5(奇数):
    • Chunk->Size + 1 变成 6(偶数),然后 6 & ~1 还是 6(因为 6 已经是偶数)。
  • 如果 Chunk->Size 是 4(偶数):
    • Chunk->Size + 1 变成 5(奇数),然后 5 & ~1 变成 4(通过按位与操作清除最低有效位)。

总结:

这行代码确保无论 Chunk->Size 是奇数还是偶数,最终的 Size 总是一个偶数。

game_asset.cpp:从 Header->Size 中减去 4

在这种情况下,可能是因为我们没有正确解释头部大小所包含的内容。所以,可能发生的情况是,RIFF 文件中的块大小(chunk size)是 4 + n。也许他们的意思是块头部的大小不包括在内,而我们错误地将其包括了进去。如果这是正确的理解,那么就可以推测出问题所在。

为了正确处理这个问题,应该将实际大小的计算从块头部的大小中减去 4,因为我们需要排除掉已经跳过的那个wave部分(wave part)。这种方式可能是更合适的解决办法。所以可以尝试这样调整,看看是否能解决问题。

调试器:逐步调试 DEBUGLoadWAV,发现正确地遍历了所有块

现在可以看看这样是否会让事情变得更清晰了。进入之后,发现有格式块(format chunk)和数据块(data chunk),除此之外没有其他块。因此,实际上我们不需要处理或跳过其他块。事实证明,这些文件是从一个叫做 Gold Wave 的程序中保存下来的,结果是文件中没有其他块,只有我们具体想要的块。这样,当我们加载时,处理就变得简单了。

game_asset.cpp:断言某些数据是有效的

现在想要做的是记住实际需要的显著数据,也就是我们真正想要获取的数据。所以我会尝试具体查找我们需要的数据。接下来,我会进行一些断言,确保我们期望的数据格式是正确的。首先,我们希望 wFormatTag 的标签是 PCM 格式。然后,我们希望通道数为 1 或 2,采样率始终是我们首选的采样率。接着,我们不太关心是否是块对齐(block aligned),但我们希望每个样本的位数总是 16 位。

网络:查找 nBlockAlign 的含义

不太清楚"channel mask"到底意味着什么,不过看起来跟位置信息有关,但不太关心这一部分。至于"block aligned data block size in bytes"是什么意思,也不清楚。查找了一些文档,发现格式块的第一个部分提到,PCM 数据经过增强之后,格式块和头部声明了每个样本的位数。原始文档规定,样本的位数应向上取整为 8 位的倍数,这个取整后的值被用作容器大小,这个信息在容器中是冗余的。每个样本的大小可以通过块大小除以通道数来确定。这个冗余信息被用来定义新格式,例如 Cool Edit 使用 4 来声明样本大小为 24 位,其他格式也根据块大小和通道数来确定。看来"block aligned"指的是通过块大小除以通道数来确定每个样本的字节数,而不是位数。

game_asset.cpp:断言 nBlockAlign 为 2 * nChannels

基本上,"block aligned"必须是 2 或 4。我们希望它等于通道数乘以 2,因为我们规定每个通道的位数必须是 16 位。所以,最终的目标是确保每个通道都为 16 位,因为这就是我们唯一支持的格式。因此,所有这些条件都必须满足,才能保证格式是正确的。

game_asset.cpp:设置 ChannelCount 和 SampleData

需要的信息是通道数,因此在代码中设定了一个 ChannelCount,初始值为 0。接着,通过读取相关数据来更新这个通道数。其他的参数目前并不需要关注,只需要这个通道数,因为在此实现中只支持这种格式。

在处理WAVE_ChunkID_data时,会提取出实际的WAVE数据。对于 PCM 格式,数据本身仅包含大小和原始数据。PCM 格式不包含其他复杂的数据结构,因此提取的就是需要的样本数据。

game_asset.cpp:断言 ChannelCount 和 SampleData 为有效

在完成数据的提取后,接下来需要确认通道数和样本数据的有效性,因此会使用 assert 来验证这两个条件,确保文件有效。之后,目标是让 load sound 功能正常工作。要实现这一点,需要知道样本的数量和内存位置,其中内存位置就是之前提取的样本数据。

在处理内存数据时,可能还需要做一些额外的操作,建议对这些数据进行进一步处理或调整,以确保在加载声音时数据的正确性和效率。

game_asset.h:向 loaded_sound 中添加 ChannelCount

考虑到需要支持不同类型的声音格式,计划对 loaded_sound 进行改进,使其支持单声道(mono)和立体声(stereo)两种声音。为此,打算引入 ChannelCount(通道计数)和 SampleCount(样本计数)的概念,以便能够处理单声道和立体声的音频文件。这样一来,未来也可以轻松扩展到支持更多通道的格式,例如杜比音效(Dolby)。

这种做法的好处在于,它不仅能支持当前的需求,还能为将来可能的音频格式扩展做好准备,避免在面对更多通道的音频时没有合适的解决方案。

网络:尝试确定如何计算样本计数,减去任何舍入部分

为了正确设置音频数据,首先需要确定样本计数。样本计数是指音频数据中实际包含的样本数量。需要考虑是否存在任何因为文件格式的要求而进行的四舍五入或填充操作。根据音频文件的格式,可能会有一些计算规则来确定样本数,例如通过块大小(block size)来推算每个样本的大小。

在查看文件格式的描述时,看到了一些信息,比如 bits per sampleblock size,这些信息对于计算样本计数很有帮助。具体来说,可以通过块大小(block size)除以每个样本的字节数来推算样本数。如果每个样本为16位(即2字节),那么可以根据块大小除以2来获得样本数。

所以,关键是在文件格式的不同字段中找到相关的信息,如块大小(block size),并根据每个样本的大小来进行计算,确保样本计数是准确的。

game_asset.cpp:计算 SampleCount

为了正确计算样本数量,首先需要知道通道数和每个样本的大小。可以通过以下方式计算:样本数量 = 通道数 × 每个样本的大小。由于每个样本的大小是16位(即2字节),可以通过这个公式来确定样本总数。

在计算时,还需要考虑样本数据的大小。因此,样本数量的计算不仅依赖于通道数,还需要用到样本数据的大小。可以通过将样本数据的总大小除以每个样本的大小来计算样本数量。因此,样本数据的大小需要作为一个重要的变量来考虑,在处理数据时也需要保持对这个值的跟踪。

在实现时,应确保正确地获取并使用数据块的大小,这样可以确保样本数量的计算是准确的。

game_asset.cpp:引入 GetChunkDataSize

为了计算样本数量,首先需要获取数据块的大小。这里的 GetChunkDataSize 方法返回的大小不包括头部数据,因此是准确的。然后,通过获取样本数据大小 (sample data size),可以进行计算,确定样本的数量。

具体来说,通过 GetChunkDataSize 方法,我们可以得到样本数据的总大小,这个大小不包含文件头部。通过此数据,结合每个样本的大小(假设每个样本大小为16位,即2字节),可以计算出总的样本数量。这个计算过程包括将样本数据的总大小除以每个样本的大小。

通过这样的方式,能够准确计算出样本的数量,并继续进行后续的数据处理和操作。

game_asset.cpp:为 SampleData 创建数组

首先,如果通道数量为 1,则处理起来非常简单。我们只需要确保结果中的通道数等于当前的通道数(即 1),然后样本数据直接指向正确的内存位置。在这种情况下,所有样本都已按顺序存储,可以直接使用。

但如果通道数量为 2(立体声),则样本数据是交替存储的,左声道和右声道交替排列,例如 左,右,左,右。这种存储方式对于处理每个通道的数据来说并不方便,因为需要分别访问每个通道的数据。因此,为了方便处理,我决定不直接使用交错的数据格式,而是将其拆开,使得每个通道的数据分别存储。

这样处理后,我们可以更加方便地访问和操作每个通道的数据,避免交错带来的复杂性。

黑板:就位进行音频通道去交错处理

在处理音频数据时,目标是将音频样本从交错存储格式(即左右声道交替存储)转换为每个声道单独存储的格式。这意味着原始的样本数据格式是像这样存储的:L0, R0, L1, R1, L2, R2,依此类推。我们希望将其转换为类似于:L0, L1, L2, L3, L4 和 R0, R1, R2, R3, R4 的格式,使得每个通道的样本数据能够独立存取。

为了实现这一点,关键的问题是如何在内存中对交错的音频数据进行"去交错"操作。首先,可以通过交换样本来实现这一目标,然而,这个过程可能并不像想象中那么简单。理想情况下,如果我们交换每一对 L 和 R 样本,那么会发现最终的结果可能并不会符合预期,因为只是简单交换并没有完全解决问题。

比如,如果将 L0 和 R0 交换,再将 L1 和 R1 交换,接着继续按此规则进行交换,最终可能得到一个看似无序的排列。而要解决这一问题,需要通过一种方式"旋转"样本顺序,让它们变得整齐。具体来说,需要在交换后按一定规则将顺序调整正确,例如通过交换某些特定位置的元素,来保证每个通道的数据正确对齐。

这种去交错的操作实际上可能并不简单,尤其是在要求数据能够就地(in-place)操作的情况下。虽然初步的交换操作看起来能起到一定作用,但对最终的样本进行排序时,如何有效地调整顺序并确保每个通道的数据正确对齐,仍然是一个需要考虑的问题。

可以通过尝试实现这些操作,或者用一组更长的样本数据来验证这一策略,看看是否能找到更有效的解决方案。

game_asset.cpp:编写左声道的 swizzling 算法

在处理音频数据时,关键步骤之一是将交错的左右声道数据(L0, R0, L1, R1等)转换为每个声道单独存储的格式(例如,L0, L1, L2, L3和R0, R1, R2, R3等)。为了实现这一目标,需要在内存中操作这些数据。

首先,确定样本数据类型为16位整数(16-bit samples),因为这是所需的格式。然后,基于每个样本数据,计算出正确的位置。由于数据是交错存储的,因此每次访问某个样本时,都需要确保读取的是正确的左或右声道样本。例如,在处理时,如果数据按照"L0, R0, L1, R1"的顺序存储,我们需要通过指针偏移来访问正确的样本,并将左声道(L)数据正确地放入新的缓冲区。

在实现过程中,需要通过循环遍历样本数据,并为每个样本在适当的位置存储其值。具体来说,首先获取当前样本的地址,通过计算偏移量来定位到正确的位置,然后将其放入目标位置,确保左声道和右声道的数据分开存储。

此外,需要注意的是,循环过程的执行顺序非常重要。在每次迭代时,左声道和右声道样本都需要从交错的数据中提取出来,存储到新的位置。通过这种方式,可以确保数据的去交错操作按预期进行。

总体来说,核心操作是通过在缓冲区中进行位置交换(swap)来实现数据的重新排列。通过这种方式,最终可以将交错存储的音频数据转换为每个声道单独存储的格式。这些操作可以逐步进行,确保每个样本的数据正确存储。

这段代码的目的是对交错存储的立体声(stereo)音频数据进行去交错(uninterleave)操作,也就是将存储在内存中的左右声道数据分别提取出来,存放到两个独立的缓冲区中。下面是对这段代码的解释,并通过一个简单的例子来说明它是如何工作的。

举例说明:

假设有以下交错存储的音频数据(只考虑简化的2个样本,假设每个样本为16位即2字节):

L0, R0, L1, R1

其中:

  • L0 是左声道的第一个样本,
  • R0 是右声道的第一个样本,
  • L1 是左声道的第二个样本,
  • R1 是右声道的第二个样本。

我们要做的事情是将交错存储的样本数据拆分为两个独立的缓冲区,分别存储左声道和右声道的数据。

步骤:

  1. 初始化:

    • Result.Samples[0] = SampleData;
      左声道的数据 L0, L1 存储在 Result.Samples[0] 中。
    • Result.Samples[1] = SampleData + (SampleDataSize / 2);
      右声道的数据 R0, R1 存储在 Result.Samples[1] 中。
  2. 交换操作:

    假设 SampleData 中的内容为:

    SampleData = [L0, R0, L1, R1]
    
    • 对于 SampleIndex = 0(即第一个样本):

      • Source = SampleData[0],即 L0
      • SampleData[0] 设置为 SampleData[0](即没有变化,因为本来就是 L0)。
      • SampleData[0] 设置为 Source,即 L0

      经过交换后,SampleData 还是:

      [L0, R0, L1, R1]
      
    • 对于 SampleIndex = 1(即第二个样本):

      • Source = SampleData[2],即 L1
      • SampleData[2] 设置为 SampleData[1](即右声道 R0)的值。
      • SampleData[1] 设置为 Source,即将左声道 L1 放到右声道的位置。

      经过交换后,SampleData 变为:

      [L0, L1, R0, R1]
      

最终效果:

这样经过去交错操作后,音频数据就被拆分成了两个独立的声道:

  • 左声道 L0, L1
  • 右声道 R0, R1

这种操作是为了将交错存储的样本数据转换为独立存储的左声道和右声道数据,使得每个声道的数据更容易单独处理。

段错误

game_asset.cpp:插入一些测试数据

当加载任何类型的WAV文件时,遇到的问题是,如果没有正确处理,左声道会正常加载,而右声道会变得完全错误。因此,想要检查右声道的输出结果,以确定问题所在。在调试时,可能暂时将数据类型从void改为uint16,这样更容易查看结果。

为了便于调试,决定在加载数据之前手动填充一些特定的值。具体来说,将样本数据的索引与其对应的值一一对应。举例来说,数据序列将从0开始,按顺序填充:0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6,这样可以方便地在分析过程中进行对比。

一个通道16字节双通道32 差一个括号

加载之后,先查看第一个32个样本,确认左声道的数据是否符合预期,结果显示左声道的数据是正确的,这与预期一致。接下来,问题出现在第二个声道(右声道)。在查看右声道数据时,结果显示为零,这显然是出错了。怀疑是代码中某些地方的计算出了问题,应该仔细检查如何处理数据。

调试器:检查右声道的值

在进行Swizzle操作后,观察到左右声道的数据分布情况相当有趣。左声道的数据按顺序递增,例如97、98、99,而右声道的数据同样也按顺序递增,但在某些部分出现了不同的模式。这些数据的分布方式看起来很奇怪,特别是在某些区域,每隔一个值就会出现不同的变化模式,有时是当前数值的一半。

观察这种数据分布,感觉要在原地进行正确的Swizzle操作会相当复杂。仔细分析后,发现数据排列方式相当奇特,不太容易找到一个简单的方法在原地完成数据重排。因此,推测可能存在一种更智能的方式来完成这个Swizzle操作,但目前还不确定具体的实现方法。

考虑到当前的情况,可以采取以下几种应对策略:

  1. 使用额外的内存:可以申请额外的缓冲区,将数据正确地拷贝并整理,而不是尝试在原地重排。
  2. 仅加载左声道:暂时只加载左声道的数据,忽略右声道。这样可以保证至少有一部分数据是正确的,而不必立即解决右声道的处理问题。
  3. 添加待办任务:在代码中标记一个待办事项,未来再考虑如何正确加载右声道的数据。

为了暂时绕开这个问题,决定仅加载左声道,并在代码中标注TODO,提醒以后再处理右声道数据。因此,暂时将声道数设为1,即:

cpp 复制代码
Result.ChannelCount = 1;

这样,即使当前无法正确完成Swizzle操作,也至少能够保证左声道数据的正确性,并避免因为右声道的数据错乱而导致更大的问题。

game.cpp 和 game.h:重新启用 tSine

有部分好像之前给删掉了现在重新修改一下

目前不太确定声音播放的具体状态,但GameOutputSound函数仍然被调用,并且确实在输出某些内容。目前的测试是一个正弦波(sine wave),但代码中似乎并没有实际定义它。不过,可以手动创建一个tSine变量用于测试。

检查代码后发现,GameState.tSine是否被递增,查看WavePeriod的值,并确认GameState.tSine = 1032。因此,当前的做法是重新插入测试代码,以验证声音输出是否仍然正常。在不确定如何继续的情况下,暂时恢复正弦波输出进行调试。

接下来,执行代码并运行,确认声音是否仍然可以播放。运行后,听到声音,证明基本的音频输出仍然有效。这说明:

  1. 声音播放机制仍然正常工作。
  2. 现阶段可以继续进行进一步的音频处理,例如加载和播放WAV文件,而不用担心底层音频系统是否正常。

接下来,可以考虑如何加载WAV文件,并确保其数据被正确处理后输出,而不是简单地使用正弦波作为测试信号。

补充正弦波声音

https://www.geogebra.org/m/vputraas

正弦波(Sine Wave)是一种最基本的波形,在声音和信号处理领域中有着重要的地位。以下是对正弦波声音的相关介绍:

1. 什么是正弦波?

正弦波是一种单一频率的波形,其数学表达式为 y ( t ) = A sin ⁡ ( 2 π f t + ϕ ) y(t) = A \sin(2\pi ft + \phi) y(t)=Asin(2πft+ϕ),其中:

  • A A A 是振幅(Amplitude),决定声音的响度;
  • f f f 是频率(Frequency),决定音调的高低(单位为赫兹 Hz);
  • t t t 是时间;
  • ϕ \phi ϕ 是相位(Phase),影响波形的起始位置。

正弦波的特点是它的波形平滑、周期性强,没有谐波(Harmonics),因此听起来非常"纯净"。

2. 正弦波的声音特性

  • 音调:正弦波的声音是单一频率的,听起来像一个纯音。例如,440 Hz 的正弦波对应于音乐中的"A4"音(国际标准音高)。
  • 音色:由于没有谐波,正弦波的音色非常简单,缺乏复杂乐器或人声那样的丰富性。人类耳朵可能会觉得它有些"单调"或"电子感"。
  • 响度:由振幅决定,振幅越大,声音越响。

3. 正弦波在现实中的应用

  • 声音合成:正弦波是合成声音的基础波形。许多电子音乐或合成器(如Moog、Roland)通过叠加正弦波来创造复杂的音色。
  • 听力测试:正弦波常用于听力检测设备(如纯音测听),因为它能精确测试人对特定频率的感知能力。
  • 信号处理:在通信和音频工程中,正弦波用于分析和调试系统。

4. 正弦波的听觉体验

如果你通过扬声器或耳机播放一个正弦波(比如用在线工具或音频软件生成),会听到一种持续的、没有起伏的"嗡嗡"声。例如:

  • 低频正弦波(20-100 Hz):听起来像深沉的低鸣,可能伴随振动感。
  • 中频正弦波(300-1000 Hz):类似电子设备的提示音。
  • 高频正弦波(2000 Hz 以上):像尖锐的哨声,可能会让人感到不适。

5. 生成正弦波声音

如果你想自己体验,可以使用免费软件(如Audacity)生成正弦波:

  1. 打开Audacity;
  2. 选择"生成" > "音调";
  3. 设置频率(如440 Hz)、振幅和持续时间;
  4. 播放即可听到。

总结

正弦波是声音世界中的"原子",简单而纯粹。虽然它本身听起来不复杂,但它是理解声波、音调和音频技术的基础。如果你有具体问题或想深入某个方面(比如生成正弦波的代码),可以告诉我!

game.h:引入 loaded_sound TestSound

我们现在想要播放一个真实的声音波形。如果要在游戏中输出其他声音,我们可以使用一个已经加载的声音,比如 TestSound

首先,我们需要找到加载声音的地方,比如 DebugLoadWAV,并将加载的声音赋值给 GameState 中的 TestSound,确保它可以在后续被使用。之前的代码中,每一帧都会重新加载声音,这虽然运行正常,但看起来有些奇怪。为了改进,我们可以调整逻辑,使得 TestSound 只在需要时加载一次,而不是每一帧都重新加载。

接下来,我们可以利用 GAME_GET_SOUND_SAMPLES 这个接口,使它执行更有意义的操作,比如实际播放 TestSound 的数据,而不仅仅是填充一个正弦波。尽管当前代码中 GameOutputSound 是一个单独的函数,但它的作用可能可以整合到 GAME_GET_SOUND_SAMPLES 里,从而更方便地管理游戏的音频输出逻辑。

game.cpp:注释掉 GameOutputSound,改用 TestSound

我们决定不再调用之前的代码,而是直接在当前逻辑中处理音频输出。具体来说,我们的目标是将 TestSound(已经加载的调试音频)的数据填充到声音缓冲区中。

首先,我们需要访问 game_state 中的 TestSound 采样数据,并将其写入声音缓冲区。我们假设 TestSound 具有 samples 数组,并从 samples[0] 开始读取数据。然后,我们根据全局采样索引 TestSampleIndex 计算当前播放的位置,并将相应的音频数据存入缓冲区。

为了让声音能够连续播放,我们需要维护 TestSampleIndex,表示当前播放的位置。这个索引在每一帧音频数据输出后需要递增,以确保下次写入缓冲区时不会重复相同的采样值,而是按照正确的顺序播放整个音频数据。

在递增 TestSampleIndex 时,需要确保不会超出 TestSound 采样数据的范围。因此,我们使用取模运算 TestSampleIndex % TestSound.SampleCount,使得索引始终处于有效范围内,防止读取超出 TestSound 的数据区域。

最后,我们确保正确写入 SampleOut,并在缓冲区中填充对应的音频数据。如果 SampleOut 位置不对,或者 SampleValue 没有正确获取 TestSound 的采样数据,则需要检查代码,确保所有的索引计算和缓冲区写入逻辑正确无误。

运行游戏,发现声音不太好

当前的声音效果并不好,因此需要进行更详细的调试和分析。

首先,我们检查 SampleIndexTestSampleIndex,确保它们正确地指向 TestSound 采样数据中的位置。这两个索引决定了我们当前读取的音频数据是否是正确的,因此需要验证它们的计算是否符合预期。

接下来,我们检查 SampleIndex,确保它正确地从 TestSound 采样数据中获取了相应的值。如果 SampleIndex 计算有误,可能会导致声音出现失真、跳跃或其他异常现象。因此,我们需要仔细验证 SampleIndex 是否与 TestSound.samples[SampleIndex] 一致,并确保数据没有越界访问或被错误修改。

然后,我们暂停代码执行,并逐步进入关键代码部分,以确认 SampleIndexSampleIndex 计算的正确性。这有助于找出可能的错误,比如索引计算错误、数据读取错误或缓冲区写入错误。

最后,我们保存当前进度,并继续分析代码逻辑,以确保所有的索引计算、采样数据读取和缓冲区填充都符合预期。如果仍然存在音质问题,我们需要进一步检查音频数据格式、缓冲区大小、数据采样率以及是否正确处理了循环播放逻辑。

发现忘记递增TestSampleIndex

调试器:逐步调试 SampleIndex 循环

我们现在检查 TestSoundSampleIndex,发现它的初始值是 0SampleValue 也是 0。然后,我们让代码继续执行,并观察 TestSampleIndex 是否按预期递增,同时检查 SampleValue 是否正确地从 TestSound 采样数据中获取。

在调试过程中,我们发现 SampleValue 没有正确输出,导致音频数据可能不符合预期。经过检查,发现 SampleValue 被误解码为无符号 uint16,而实际上音频数据是 有符号 int16 。这是一个关键问题,因为如果使用无符号类型,负数采样值会被错误地解释为很大的正数,从而导致声音失真或完全错误。更改为 int16 之后,这应该能解决一部分问题。

除此之外,我们还检查 SampleCount,发现它的值是 1066,表示当前 TestSound 采样数据的总数。在代码运行过程中,TestSampleIndex 按预期递增,说明索引计算大体上是正确的。

下一步,我们继续验证所有索引计算、数据读取和缓冲区填充逻辑,确保 TestSound.samples[TestSampleIndex] 读取的值是正确的 有符号 16 位整数 ,并且 TestSampleIndexSampleCount 之内正确循环。修复 int16 误用问题后,应该能改善音频播放效果,但仍需进一步测试,以确保声音播放正常。

game.cpp:将 Sample 数据设置为 int16

经过检查,代码整体上看起来是相对正确的。但是,之前错误地使用了 uint16(无符号 16 位整数)来存储音频数据,而实际上音频数据是以 int16(有符号 16 位整数)编码的。这是一个重要的错误,因为音频数据需要包含负值,以正确表示波形。如果使用 uint16,负数部分会被错误地解释为很大的正数,导致声音播放异常。

这个错误主要是因为长时间没有编写音频处理代码,导致在数据类型选择上出现疏忽。在代码的多个地方,都应该将 uint16 改为 int16,确保音频数据能够正确存储和解析。

修正数据类型后,当前实现应该更符合预期,能够正确解码和输出声音。接下来,需要进行进一步的测试,以确保所有 int16 相关的计算、索引操作和缓冲区填充都是正确的,避免因数据类型问题导致的音频失真或其他异常现象。

运行游戏,依然听到点击声

目前声音仍然不正确,听起来像是"点击"声,而不是预期的音频效果。因此,我们需要进一步分析问题的原因。

首先,检查当前加载的音频文件是否正确。回顾之前的逻辑,我们加载了 bloop_00 这个音频文件,因此需要确认它的内容是否符合预期。可以通过播放 bloop_00 的原始文件,验证它的实际声音是否正确。如果原始文件听起来正常,那么问题可能出在加载或播放的代码上。

接下来,我们需要检查音频数据在加载时是否被正确解析。例如:

  • 采样格式是否匹配 :确认 bloop_00 的音频格式是否与代码中的解析方式一致,例如 16-bit PCM采样率单声道/立体声 等。
  • 数据是否完整 :检查 TestSound.samples 是否正确填充了音频数据,确保数据没有丢失或截断。
  • 缓冲区写入是否正确 :检查 SampleIndex 计算是否准确,是否正确遍历 TestSound.samples,并且写入到缓冲区时数据没有错位或重复。

最后,可以尝试逐步调试音频输出流程,比如:

  1. 直接在代码中打印 TestSound.samples 的前几个值,确认数据是否合理。
  2. TestSound.samples 写入一个文件,再用外部工具(如 Audacity)查看波形是否正常。
  3. 调整播放逻辑,尝试不同的缓冲区大小、不同的采样率,观察声音是否有所变化。
    Audacity 是这个东西

通过这些方法,可以逐步缩小问题范围,找出导致"点击"声的真正原因,并最终修正音频播放的逻辑。

听取声音

我们遇到了一些音频问题,特别是在播放时出现了点击声,这似乎是 VLC 播放器的一个 bug,尽管如此,至少可以听到其中的 "bloop" 音效部分。为了避免这个问题,我们打算换一个播放器进行尝试,希望能找到一个不会出现这么大问题的播放器。

接下来,我们需要找出出错的原因,分析并进行修复。我们可以稍微进入一些质量保证的步骤,这样可以确保在继续开发之前,当前的状态已经达到一个合理的水平,这样明天我们就可以从音频部分开始讨论,并确保能够顺利加载所需的资源。通过这样做,我们可以更有条理地推进工作,避免出现无法解决的问题。

总的来说,我们的目标是尽快理清现有的问题,并且将其解决,以便后续的进展能够顺利进行。在这个过程中,我们还需要处理和测试音频样本,确保在不同的播放器和设置下都能顺畅播放。

game.cpp:检查 SampleIndex 循环

当前我们处理的是立体声输出,写入了左右声道,并且这是按预期进行的。我们只是将相同的内容写入左右声道,这应该是没有问题的。我们从测试音频中获取了第一个样本通道的数据,这是我们预期的操作,而且我们是按顺序获取数据的,同时还对音频缓冲区进行了循环处理。所以理论上,音频应该会反复播放,形成一个连续的循环。

目前的情况看起来还算正常,没有明显的错误。我现在最想做的是再次检查加载代码,因为我对加载部分的实现有一些担心。相比之下,我对播放代码的关注度要稍微低一些。虽然这只是一个猜测,但这是我目前的直觉。

因此,我们需要深入查看加载音频的部分,确保一切按预期进行,避免在这一部分出现问题,从而影响后续的音频播放处理。

调试器:进入并检查 SampleData

我们想要查看音频采样数据,以确认它看起来是否符合预期。首先,查看这些采样数据的值,发现它们看起来像是合理的声音数据,至少在某种程度上可以认为是可信的。这些数据波动看起来也符合常见的声音数据模式,尽管波动比较快速,但从整体上看是可以接受的。

进一步观察时,发现这些数据是紧密排列的,并且是立体声样本,即每两个连续的样本分别对应左声道和右声道。通过查看这些值,像是 -21-11-244264-60 等,感觉这些数值有些异常,可能并不像预期的那样平滑或正常。这引发了怀疑,觉得它们可能有些不对劲。

因此,决定返回去仔细检查音频文件的格式,看看是否在解析过程中遗漏了什么关键步骤或错误。可能在解码时出现了问题,导致数据异常,需要进一步确认格式是否正确处理,或者是否存在其他解析错误。

网络:查看 PCM 数据

我们需要查看PCM数据的实际内容,首先确定我们正在处理的是PCM数据,而不是其他格式。具体来说,我们关心的是是否正确处理了通道交织数据,每个样本的字节数以及每个样本的位数,应该是八倍样本数。所以目前看起来我们在解释数据时应该是正确的。

不过,我们还需要考虑是否存在数据的排列顺序(Swizzle)问题。我们需要确认是否所有的数据都是双通道的,这样我们才能判断问题是否出在数据排列的处理上。为了排查这个问题,可以测试一下是否只是我们的排列处理代码有问题。

但在进行这些操作之前,首先确认一下在我们进行排列处理之前,数据本身是否已经符合预期。之前我们曾通过零、一个、二、三、四这样的方式检查过数据,所以我们可以再检查一遍,确保排列操作(Swizzle)按我们预期的方式工作。

game_asset.cpp:移除测试代码

问题终于找到了,原来是测试代码没有移除。哈哈,这真是太搞笑了!确实,如果在测试代码中写了很多无意义的数据,结果就不应该感到惊讶,因为它不会发出正常的声音。这真是个笑话,但也是个很好的教训。

现在我们明白了问题的根源,只是忘了清理测试时使用的那些垃圾数据。这样一来,输出的音频自然就不会像预期的声音一样了。

运行游戏,听到更正确的声音

现在情况有所改善,音频加载和播放已经正确进行,但仍然存在一些问题。虽然音频能够正确加载和播放,但似乎没有达到预期的帧率。这意味着虽然音频在播放时没有明显的错误,但可能因为帧率的问题导致播放效果不够流畅,需要进一步检查并解决帧率方面的瓶颈。

目前的计划是切换到 -O2 优化级别进行构建。我怀疑,如果我们继续构建,现在可能会对帧率有所影响,不过如果这样做,应该会变得更好。然而,我们还需要确保将帧率同步到正确的值,而不是固定在 60Hz。所以,首先需要回到最初的设置,并进行重置。

接下来,我们要检查当前的设置,并确保在构建之后可以正确调整帧率,以确保程序能够按预期运行,不会因为帧率问题导致性能不稳定。

win32_game.cpp:将 GameUpdateHz 设置为 MonitorRefreshHz / 2.0f

目前的显示器刷新率是 60Hz,所以我们打算暂时将设置调整为 60Hz。这样做是为了确保与显示器的刷新率保持一致,避免出现因帧率不同步而导致的问题。因此,我们决定先按这个设置进行,确保程序能够正常运行。

这个已经写死

运行游戏,声音播放正确

现在应该会好很多。接下来可以进入质量保证阶段,不过为了避免让大家对当前的声音感到烦躁,我们可能会将音频切换成钢琴音乐。我记得我在这里添加了来自 ad-lib 库的音频文件,具体是哪个文件呢?应该是 ../../../../game_test_asset/data/test3/music_test.wav,这个文件可以用来替代现在的音效,避免长时间播放同一声音造成不适。

game.cpp:播放 music_test.wav

好的,现在我们换成了一个更加平和的音频,播放一些不会引起不适的声音,这样就没问题了。这个新的音频更为温和,不会像之前的那样让人感到烦躁,应该会更合适。

你可以尝试像这样去交错通道:http://imgur.com/ZcDu4Tb

有一个建议是尝试将通道交织操作直接放在当前的位置进行处理。有人提供了一个链接,我查看了一下,链接内容似乎是关于交换通道的操作,具体来说是从开始和结束的位置进行交换,类似于内外交换的操作。我在想,这种方法是否有效?是否能够解决问题?

不过,有一个不确定的地方是,这种操作是否对较长的音频序列有效。对于较长的序列,可能会出现一些不同的表现,这一点还需要进一步确认。所以,现在的问题是,是否能够适用于长序列,还是只有在短序列中才有效?这个仍然是一个需要验证的疑问。

去交错的第二容易方法是多次传递。交换 R0/L1, R2/L3, L4/L5 等。然后在两个样本块、四个样本块中做同样的事。最简单的方法是直接在混合/输出缓冲区中去交错。

有一个建议是,通过多次交换(multi-pass swap)来进行通道交织,这种方法相对简单。具体操作是先交换 R0R1,然后交换 L1, R2, L3, L4, L5,接着继续对两个样本块进行相同的交换。这样的方法可以直接将交织后的数据放入混音输出缓冲区。然而,考虑到效率,我们并不希望采取这种方法。我们更倾向于在构造时就完成去交织(D-interleave)的操作,这样在生成的艺术包(art pack)中,数据已经是去交织好的。

这样做的原因是,提前处理交织操作可以避免在后续处理时的额外复杂性,从而提高效率。我们不需要在每次运行时都进行去交织的操作,直接在构建时完成能更简便且高效。

为什么要去交错声音?播放时你还得读取左右声道的值

在播放声音时,即使已经去交织(uninterleave),仍然需要读取左右声道的值。这是因为左右声道的处理方式不同,我们需要分别处理这些值。为了达到更广泛的兼容性和灵活性,我们希望能够更精细地控制左右声道的数据处理,确保每个声道的数据都能按照预期的方式处理。因此,去交织操作后,左右声道的数据仍然需要被分别读取和处理。

有人在问 extern C 的好处是什么?它解决了什么问题?

extern "C" 主要解决了几个问题,首先,它防止了函数名被修改。通常在链接时,C++ 会对函数名进行修饰,以确保操作符重载等特性在链接器不支持的情况下能正常工作。而 extern "C" 则能防止这种修饰,使得函数名保持原样,这对于动态链接库(DLL)尤其重要。举个例子,当将游戏的入口点作为 DLL 导出时,如果没有使用 extern "C",DLL 导入表中的函数名会被修改,影响正确导入。

其次,extern "C" 还指定了调用约定。在 Windows 的 64 位系统上,通常只有一个调用约定,即标准调用(standard call),因此通常没有问题。但在 32 位系统上,存在多种调用约定,比如 Pascal 调用约定、C 风格调用约定等,使用 extern "C" 可以明确指定使用 C 风格的调用约定,尤其是在涉及可变参数时(例如 varargs)会更加重要。

总的来说,当需要进行跨语言互操作性时,extern "C" 就非常有用,它确保了链接和调用的方式正确无误。如果只是编译自己的代码,不涉及外部链接,通常不需要使用 extern "C",因为在这种情况下,函数名修改和调用约定问题并不重要。

你能解释一下: (Chunk->Size + 1) & -1 吗?

在这个解释中,主要是讲解了如何通过对"块大小"进行加一和与负一按位与操作来确保数据对齐。

首先,解释了如何判断一个数字是奇数还是偶数:在二进制中,最低位(最低有效位)决定了一个数字是否为奇数,如果该位为1,数字就是奇数。如果最低位为0,数字就是偶数。而在块大小的处理过程中,规范要求如果块大小是奇数,则需要对齐到偶数大小。因此,如果块大小是奇数(例如15),需要在它后面插入一个空字节,使其成为偶数大小(例如16)。

为了实现这个对齐,首先可以将块大小加1,这样可以确保无论当前的块大小是奇数还是偶数,都能正确地进行向上取整。对于奇数大小(比如15),加1后得到16,清除最低位后就得到了一个偶数大小。如果块大小本来就是偶数(例如16),加1后得到17,再通过按位与负一操作清除最低位,从而确保最终的块大小依然是偶数,并且满足对齐要求。

这其实是一种常见的对齐模式,类似于对齐到 16 字节边界的方式(例如使用 +15~15 的方式)。通过这种方式,能确保块大小始终向上取整到最接近的偶数或其他的 2 的幂次方的倍数。

总结起来,就是通过"块大小加1"再与"-1"进行按位与操作,确保数据对齐,避免因为奇数块大小而导致的不对齐问题,这种方法适用于任何 2 的幂次方对齐问题。

为什么样本是交错的,而不是平的?

关于为什么音频数据是交织(interleaved)而不是平铺(flat)的,原因可能是因为早期的声卡通常是以这种方式处理数据的。过去,音频数据经常是交织发送的,这样的做法是当时硬件和处理方式的需求。

然而,现代的音频处理已经不再需要使用这种交织方式,因为现在的音频处理通常是在可变数量的声道上进行的。例如,可能是2声道、5声道或者更多声道。交织数据会增加额外的处理负担,这对于灵活的音频输出(如支持不同的声道输出)来说并不是高效的做法。因此,虽然早期声卡使用交织方式,但如今这种方式已经不再适用,因为它增加了不必要的复杂性和工作量。

总的来说,虽然历史上使用交织数据格式有其原因,但随着音频处理的需求变得更加灵活和高效,现代的音频系统通常会避免使用交织方式,转而采用更简单、直接的数据格式。

如果把交错的数据看作是 Nx2 矩阵,转置就可以看作是交错。这里有一种就地做的方法:https://goo.gl/fgPmrg

https://en.wikipedia.org/wiki/In-place_matrix_transposition#Non-square_matrices:_Following_the_cycles

在这个解释中,讨论了如何实现或者将数据保持为一个 N 行 2 列的矩阵,并将转置视为交织操作。提到了一种就地(in-place)实现的方法。具体的过程是:

  1. 初始化:首先,对于大于 1 的长度,遍历每个排列,并选择一个起始地址(S&C)。
  2. 数据存储 :设定 d 等于起始地址 S 处的数据。
  3. 保存数据:将起始地址 S 处的数据保存在一个变量 X 中。
  4. 循环操作 :当 X 不等于 S 时,进行以下操作:
    • 从 X 中移动数据到 S 的下一个位置。
    • 更新 X 为 S 的前驱位置。
    • 将数据从 D 移动到测试位置。

这个方法的核心思想是通过循环和数据移动的方式,实现就地的数据转置或者交织操作,而不需要额外的内存分配。 需要注意的是,实际实现时需要仔细处理数据的位置和顺序,确保不会丢失任何数据并且操作的效率较高。

这段说明看起来像是一个需要仔细阅读和理解的算法,尤其是在处理数据搬移和循环中的细节时。

在使用 arena(内存分配器)时,创建了一个子 arena 用于游戏资产,但在操作过程中未正确地抓取一些数组数据。提出了是否应该将这些数据也放入 arena 的讨论,尽管最终认为这可能并不一定是一个 bug,而是最初设计的目的。

在考虑了这个问题后,表达了对该设计的重新理解,认为这种分配方式是合理的,因为它分别处理了结构性信息和数据性信息,因此没有必要将所有内容都放入 arena 中。最终,得出了这个问题并非 bug,而是按预期设计进行的结论。

为什么要就地更改 PCM 数据,而不是将其压缩成你需要的信息并分配额外的空间?

在这段话中,首先提到了关于是否需要直接在运行时修改 PCM 数据的问题。这个问题更多是出于好奇,而非实际需求。实际上,在运行时并不需要进行这样的修改,因为资产处理器(Asset Processor)会在加载过程中将数据整理成正确的频道顺序。因此,不需要在程序中手动改变 PCM 数据,也不需要额外分配空间来精简数据,因为资产处理器会在预处理阶段完成这一工作。

接下来,提到原本尝试在运行时对数据进行交错处理(interleave)时,发现虽然可以做到,但需要更加巧妙的方法,而不是简单地进行处理。最终,得出了一个结论:虽然可以做,但并不值得在运行时进行,因为处理器会在前期做好这部分工作。

相关推荐
技术小齐1 小时前
网络运维学习笔记(DeepSeek优化版) 014网工初级(HCIA-Datacom与CCNA-EI)NAT网络地址转换
运维·网络·学习
llkk星期五1 小时前
zotero同步infiniCLOUD报错:webdav服务器不接受您输入的用户名及密码
学习
周周记笔记1 小时前
学习笔记:Python网络编程初探之基本概念(一)
笔记·学习
烂蜻蜓1 小时前
深入理解 HTML 元素:构建网页的基础
开发语言·前端·css·html·html5
第七玩家3 小时前
React-异步队列执行方法useSyncQueue
前端·javascript·react.js
少年姜太公3 小时前
让你快速拿捏大厂面试中关于eventloop执行顺序问题
前端·javascript·面试
虾球xz7 小时前
游戏引擎学习第139天
linux·学习·游戏引擎
m0_748238277 小时前
Nginx解决前端跨域问题
运维·前端·nginx
轻口味7 小时前
【每日学点HarmonyOS Next知识】截图组件截取列表、Toggle组件、Web组件请求头、列表选择弹窗、游戏加速
前端·游戏·harmonyos·harmonyosnext
虾球xz7 小时前
游戏引擎学习第141天
学习·游戏引擎